diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index 513fa70..db081e0 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -124,7 +124,6 @@ public class Controlify implements ControlifyApi { } public void tick(Minecraft client) { - var minecraft = Minecraft.getInstance(); if (minecraft.getOverlay() == null) { if (!calibrationQueue.isEmpty()) { Screen screen = minecraft.screen; diff --git a/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuideApi.java b/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuideApi.java index b28c33c..53e2d55 100644 --- a/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuideApi.java +++ b/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuideApi.java @@ -12,7 +12,7 @@ import java.util.function.Function; * Adds a guide to a button. This does not invoke the button press on binding trigger, only renders the guide. * This should be called every time a button is initialised, like in {@link Screen#init()} */ -public interface ButtonGuideApi { +public final class ButtonGuideApi { /** * Makes the button render the image of the binding specified. * This does not invoke the button press on binding trigger, only renders the guide. @@ -23,7 +23,7 @@ public interface ButtonGuideApi { * @param position where the guide should be rendered relative to the button * @param renderPredicate whether the guide should be rendered */ - static void addGuideToButton( + public static void addGuideToButton( T button, Function, ControllerBinding> binding, ButtonRenderPosition position, diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/vanilla/ScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/ScreenMixin.java similarity index 57% rename from src/main/java/dev/isxander/controlify/mixins/feature/screenop/vanilla/ScreenMixin.java rename to src/main/java/dev/isxander/controlify/mixins/feature/screenop/ScreenMixin.java index f7593ce..8c782fd 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/vanilla/ScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/ScreenMixin.java @@ -1,7 +1,9 @@ -package dev.isxander.controlify.mixins.feature.screenop.vanilla; +package dev.isxander.controlify.mixins.feature.screenop; +import dev.isxander.controlify.screenop.ComponentProcessorProvider; import dev.isxander.controlify.screenop.ScreenProcessorProvider; import dev.isxander.controlify.screenop.ScreenProcessor; +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; @@ -21,6 +23,13 @@ public class ScreenMixin implements ScreenProcessorProvider { @Inject(method = "rebuildWidgets", at = @At("RETURN")) private void onScreenInit(CallbackInfo ci) { - screenProcessor().onWidgetRebuild(); + // cannot use screenProcessor() because it may be overriden by registry + ScreenProcessorProvider.provide((Screen) (Object) this).onWidgetRebuild(); + } + + @Inject(method = "init(Lnet/minecraft/client/Minecraft;II)V", at = @At("HEAD")) + private void clearRegistryCaches(Minecraft client, int width, int height, CallbackInfo ci) { + ScreenProcessorProvider.REGISTRY.clearCache(); + ComponentProcessorProvider.REGISTRY.clearCache(); } } diff --git a/src/main/java/dev/isxander/controlify/screenop/ComponentProcessorProvider.java b/src/main/java/dev/isxander/controlify/screenop/ComponentProcessorProvider.java index 73976e4..3035ef4 100644 --- a/src/main/java/dev/isxander/controlify/screenop/ComponentProcessorProvider.java +++ b/src/main/java/dev/isxander/controlify/screenop/ComponentProcessorProvider.java @@ -8,6 +8,10 @@ public interface ComponentProcessorProvider { static ComponentProcessor provide(GuiEventListener component) { if (component instanceof ComponentProcessorProvider provider) return provider.componentProcessor(); - return ComponentProcessor.EMPTY; + + return REGISTRY.get(component).orElse(ComponentProcessor.EMPTY); + } + + Registry REGISTRY = new Registry<>(); } diff --git a/src/main/java/dev/isxander/controlify/screenop/Registry.java b/src/main/java/dev/isxander/controlify/screenop/Registry.java new file mode 100644 index 0000000..0734eb2 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/screenop/Registry.java @@ -0,0 +1,50 @@ +package dev.isxander.controlify.screenop; + +import org.jetbrains.annotations.ApiStatus; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +@SuppressWarnings("unchecked") +public class Registry { + private final Map, Function> registry; + private final Map cache; + + public Registry() { + this.registry = new HashMap<>(); + this.cache = new HashMap<>(); + } + + /** + * Registers a constructor for a class + * + * @param clazz the class to bind the constructor to + * @param constructor function to build the object from the class + * @param type of class + */ + public void register(Class clazz, Function constructor) { + registry.put(clazz, (Function) constructor); + } + + Optional get(T object) { + U cached = this.cache.get(object); + if (cached != null) + return Optional.of(cached); + + Class clazz = (Class) object.getClass(); + Function constructor = registry.get(clazz); + if (constructor == null) + return Optional.empty(); + + U constructed = constructor.apply(object); + this.cache.put(object, constructed); + return Optional.of(constructed); + } + + @ApiStatus.Internal + public void clearCache() { + this.cache.clear(); + } +} diff --git a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorProvider.java b/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorProvider.java index 29fac23..981e4eb 100644 --- a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorProvider.java +++ b/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorProvider.java @@ -2,10 +2,20 @@ package dev.isxander.controlify.screenop; import net.minecraft.client.gui.screens.Screen; +import java.util.Optional; + public interface ScreenProcessorProvider { ScreenProcessor screenProcessor(); static ScreenProcessor provide(Screen screen) { + Optional> optional = REGISTRY.get(screen); + if (optional.isPresent()) return optional.get(); + return ((ScreenProcessorProvider) screen).screenProcessor(); } + + /** + * Register a screen processor for a screen from an entrypoint + */ + Registry> REGISTRY = new Registry<>(); } diff --git a/src/main/resources/controlify.mixins.json b/src/main/resources/controlify.mixins.json index ef7176b..1651d78 100644 --- a/src/main/resources/controlify.mixins.json +++ b/src/main/resources/controlify.mixins.json @@ -30,6 +30,7 @@ "feature.guide.screen.AbstractButtonMixin", "feature.guide.screen.AbstractWidgetMixin", "feature.guide.screen.TabNavigationBarMixin", + "feature.screenop.ScreenMixin", "feature.screenop.vanilla.AbstractButtonMixin", "feature.screenop.vanilla.AbstractContainerEventHandlerMixin", "feature.screenop.vanilla.AbstractContainerScreenMixin", @@ -44,7 +45,6 @@ "feature.screenop.vanilla.LanguageSelectionListEntryMixin", "feature.screenop.vanilla.OptionsSubScreenAccessor", "feature.screenop.vanilla.ScreenAccessor", - "feature.screenop.vanilla.ScreenMixin", "feature.screenop.vanilla.SelectWorldScreenAccessor", "feature.screenop.vanilla.SelectWorldScreenMixin", "feature.screenop.vanilla.ServerSelectionListEntryMixin", diff --git a/src/testmod/java/dev/isxander/controlify/test/ClientTestHelper.java b/src/testmod/java/dev/isxander/controlify/test/ClientTestHelper.java index 6ccb32d..2e8145d 100644 --- a/src/testmod/java/dev/isxander/controlify/test/ClientTestHelper.java +++ b/src/testmod/java/dev/isxander/controlify/test/ClientTestHelper.java @@ -1,17 +1,13 @@ package dev.isxander.controlify.test; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import dev.isxander.controlify.bindings.ControllerBindings; import dev.isxander.controlify.controller.Controller; -import dev.isxander.controlify.controller.ControllerConfig; -import dev.isxander.controlify.controller.ControllerState; -import dev.isxander.controlify.controller.ControllerType; import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; import java.time.Duration; import java.time.LocalDateTime; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import java.util.function.Predicate; @@ -52,86 +48,21 @@ public class ClientTestHelper { return submit(function).join(); } - public static Controller createFakeController() { - return new Controller<>() { - private final ControllerBindings bindings = new ControllerBindings<>(this); - private final ControllerConfig config = new ControllerConfig() { - @Override - public void setDeadzone(int axis, float deadzone) { + public static void takeScreenshot(String name) { + AtomicBoolean returned = new AtomicBoolean(false); + submitAndWait(mc -> { + Screenshot.grab(mc.gameDirectory, name+".png", mc.getMainRenderTarget(), text -> returned.set(true)); + return true; + }); + waitFor("Screenshot to be taken", mc -> returned.get(), Duration.ofSeconds(2)); - } + } - @Override - public float getDeadzone(int axis) { - return 0; - } - }; - - @Override - public String uid() { - return "FAKE"; - } - - @Override - public int joystickId() { - return -1; - } - - @Override - public ControllerBindings bindings() { - return bindings; - } - - @Override - public ControllerConfig config() { - return config; - } - - @Override - public ControllerConfig defaultConfig() { - return config; - } - - @Override - public void resetConfig() { - - } - - @Override - public void setConfig(Gson gson, JsonElement json) { - - } - - @Override - public ControllerType type() { - return ControllerType.UNKNOWN; - } - - @Override - public String name() { - return "FAKE CONTROLLER"; - } - - @Override - public ControllerState state() { - return ControllerState.EMPTY; - } - - @Override - public ControllerState prevState() { - return ControllerState.EMPTY; - } - - @Override - public void updateState() { - - } - - @Override - public void clearState() { - - } - }; + public static FakeController createAndUseDummyController() { + var controller = new FakeController(); + Controller.CONTROLLERS.put(controller.uid(), controller); + controller.use(); + return controller; } } diff --git a/src/testmod/java/dev/isxander/controlify/test/ControlifyAutoTestClient.java b/src/testmod/java/dev/isxander/controlify/test/ControlifyAutoTestClient.java index 7332d2d..6033c36 100644 --- a/src/testmod/java/dev/isxander/controlify/test/ControlifyAutoTestClient.java +++ b/src/testmod/java/dev/isxander/controlify/test/ControlifyAutoTestClient.java @@ -10,7 +10,7 @@ import java.util.List; import static dev.isxander.controlify.test.ClientTestHelper.*; public class ControlifyAutoTestClient implements ClientModInitializer { - private static final Logger LOGGER = LoggerFactory.getLogger("Controlify Auto Test"); + public static final Logger LOGGER = LoggerFactory.getLogger("Controlify Auto Test"); @Override public void onInitializeClient() { @@ -36,8 +36,8 @@ public class ControlifyAutoTestClient implements ClientModInitializer { for (var method : methods) { method.setAccessible(true); - Test.PreLoad preLoad = method.getAnnotation(Test.PreLoad.class); - if (preLoad != null) { + Test.Entrypoint entrypoint = method.getAnnotation(Test.Entrypoint.class); + if (entrypoint != null) { if (method.getParameterCount() > 0) throw new RuntimeException("PreLoad test method " + method.getName() + " has parameters!"); @@ -47,11 +47,11 @@ public class ControlifyAutoTestClient implements ClientModInitializer { } catch (Exception e) { throw new RuntimeException(e); } - }, preLoad.value())); + }, entrypoint.value())); } - Test.PostLoad postLoad = method.getAnnotation(Test.PostLoad.class); - if (postLoad != null) { + Test.TitleScreen titleScreen = method.getAnnotation(Test.TitleScreen.class); + if (titleScreen != null) { if (method.getParameterCount() > 0) throw new RuntimeException("PostLoad test method " + method.getName() + " has parameters!"); @@ -61,7 +61,7 @@ public class ControlifyAutoTestClient implements ClientModInitializer { } catch (Exception e) { throw new RuntimeException(e); } - }, postLoad.value())); + }, titleScreen.value())); } } @@ -88,11 +88,13 @@ public class ControlifyAutoTestClient implements ClientModInitializer { private boolean wrapTestExecution(Test test) { LOGGER.info("\u001b[36mRunning test " + test.name() + "..."); try { - test.method().run(); + test.runTest(); LOGGER.info("\u001b[32mPassed test " + test.name() + "!"); + takeScreenshot(test.name()); return true; } catch (Throwable t) { LOGGER.error("\u001b[31mFailed test " + test.name() + "!", t); + takeScreenshot(test.name()); return false; } } diff --git a/src/testmod/java/dev/isxander/controlify/test/ControlifyTests.java b/src/testmod/java/dev/isxander/controlify/test/ControlifyTests.java index eccb4dc..002d45b 100644 --- a/src/testmod/java/dev/isxander/controlify/test/ControlifyTests.java +++ b/src/testmod/java/dev/isxander/controlify/test/ControlifyTests.java @@ -6,6 +6,10 @@ import dev.isxander.controlify.api.bind.ControlifyBindingsApi; import dev.isxander.controlify.api.event.ControlifyEvents; import dev.isxander.controlify.bindings.BindingSupplier; import dev.isxander.controlify.bindings.GamepadBinds; +import dev.isxander.controlify.screenop.ScreenProcessor; +import dev.isxander.controlify.screenop.ScreenProcessorProvider; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.TitleScreen; import net.minecraft.resources.ResourceLocation; import java.util.concurrent.atomic.AtomicBoolean; @@ -15,8 +19,9 @@ import static dev.isxander.controlify.test.ClientTestHelper.*; public class ControlifyTests { BindingSupplier binding = null; + boolean titleScreenProcessorPresent = false; - @Test.PreLoad("Binding registry test") + @Test.Entrypoint("Binding registry test") void bindingRegistryTest() { var registry = ControlifyBindingsApi.get(); assertNotNull(registry, "Binding registry is null"); @@ -24,13 +29,14 @@ public class ControlifyTests { assertNotNull(binding, "Bind registry failed - BindingSupplier is null"); } - @Test.PostLoad("BindingSupplier getter test") + @Test.TitleScreen("BindingSupplier getter test") void bindingSupplierGetterTest() { - var controller = createFakeController(); + var controller = createAndUseDummyController(); assertNotNull(binding.get(controller), "Bind registry failed - Bind for fake controller is null"); + controller.finish(); } - @Test.PostLoad("Input mode changed event test") + @Test.TitleScreen("Input mode changed event test") void checkInputModeChangedEvent() { var api = ControlifyApi.get(); @@ -42,4 +48,20 @@ public class ControlifyTests { assertTrue(called.get(), "Input mode changed event was not called"); } + + @Test.Entrypoint("Screen component registry setup test") + void setupScreenComponentRegistry() { + ScreenProcessorProvider.REGISTRY.register(TitleScreen.class, ts -> new ScreenProcessor<>(ts){ + @Override + public void onWidgetRebuild() { + super.onWidgetRebuild(); + titleScreenProcessorPresent = true; + } + }); + } + + @Test.TitleScreen("Screen component registry test") + void checkScreenComponentRegistry() { + assertTrue(titleScreenProcessorPresent, "Screen processor was not called"); + } } diff --git a/src/testmod/java/dev/isxander/controlify/test/DiscoveredTests.java b/src/testmod/java/dev/isxander/controlify/test/DiscoveredTests.java new file mode 100644 index 0000000..dec3c6d --- /dev/null +++ b/src/testmod/java/dev/isxander/controlify/test/DiscoveredTests.java @@ -0,0 +1,10 @@ +package dev.isxander.controlify.test; + +import java.util.List; + +public record DiscoveredTests(List entrypointTests, List titleScreenTests) { + public boolean hasRanAllTests() { + return entrypointTests.stream().allMatch(Test::hasRan) + && titleScreenTests.stream().allMatch(Test::hasRan); + } +} diff --git a/src/testmod/java/dev/isxander/controlify/test/FakeController.java b/src/testmod/java/dev/isxander/controlify/test/FakeController.java new file mode 100644 index 0000000..1f81c35 --- /dev/null +++ b/src/testmod/java/dev/isxander/controlify/test/FakeController.java @@ -0,0 +1,178 @@ +package dev.isxander.controlify.test; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.bindings.ControllerBindings; +import dev.isxander.controlify.controller.Controller; +import dev.isxander.controlify.controller.ControllerType; +import dev.isxander.controlify.controller.joystick.JoystickConfig; +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 java.util.List; + +public class FakeController implements JoystickController { + public static int JOYSTICK_COUNT = 0; + + private final String uid; + private final int id; + private final ControllerBindings bindings; + private final JoystickConfig config; + private JoystickState state = JoystickState.EMPTY, prevState = JoystickState.EMPTY; + + private float axisState; + private boolean shouldClearAxisNextTick; + private boolean buttonState, shouldButtonPressNextTick; + private JoystickState.HatState hatState = JoystickState.HatState.CENTERED; + private boolean shouldCenterHatNextTick; + + public FakeController() { + this.uid = "FAKE-" + JOYSTICK_COUNT++; + this.id = -JOYSTICK_COUNT; + this.bindings = new ControllerBindings<>(this); + this.config = new JoystickConfig(this); + this.config.calibrated = true; + } + + @Override + public String uid() { + return uid; + } + + @Override + public int joystickId() { + return id; + } + + @Override + public ControllerBindings bindings() { + return bindings; + } + + @Override + public JoystickState state() { + return state; + } + + @Override + public JoystickState prevState() { + return prevState; + } + + @Override + public JoystickConfig config() { + return config; + } + + @Override + public JoystickConfig defaultConfig() { + return config; + } + + @Override + public void resetConfig() { + throw new UnsupportedOperationException(); + } + + @Override + public void setConfig(Gson gson, JsonElement json) { + throw new UnsupportedOperationException(); + } + + @Override + public ControllerType type() { + return ControllerType.UNKNOWN; + } + + @Override + public String name() { + return "Fake Controller"; + } + + @Override + public void updateState() { + buttonState = shouldButtonPressNextTick; + shouldButtonPressNextTick = false; + + state = new FakeControllerState(mapping(), axisState, buttonState, hatState); + + if (shouldClearAxisNextTick) { + shouldClearAxisNextTick = false; + axisState = 0f; + } + if (shouldCenterHatNextTick) { + shouldCenterHatNextTick = false; + hatState = JoystickState.HatState.CENTERED; + } + } + + @Override + public void clearState() { + state = JoystickState.EMPTY; + } + + @Override + public JoystickMapping mapping() { + return UnmappedJoystickMapping.INSTANCE; + } + + @Override + public int axisCount() { + return 1; + } + + @Override + public int buttonCount() { + return 1; + } + + @Override + public int hatCount() { + return 1; + } + + public void setAxis(float axis, boolean clearNextTick) { + this.axisState = axis; + this.shouldClearAxisNextTick = clearNextTick; + } + + public void clearAxisNextTick() { + this.shouldClearAxisNextTick = true; + } + + public void pressButtonNextTick() { + this.shouldButtonPressNextTick = true; + } + + public void setHat(JoystickState.HatState hatState, boolean clearNextTick) { + this.hatState = hatState; + this.shouldCenterHatNextTick = clearNextTick; + } + + public void clearHatNextTick() { + this.shouldCenterHatNextTick = true; + } + + public void use() { + Controlify.instance().setCurrentController(this); + } + + public void finish() { + Controlify.instance().setCurrentController(null); + Controller.CONTROLLERS.remove(uid, this); + } + + @Override + public boolean canBeUsed() { + return true; + } + + 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)); + } + } +} diff --git a/src/testmod/java/dev/isxander/controlify/test/Test.java b/src/testmod/java/dev/isxander/controlify/test/Test.java index b4a088c..786f973 100644 --- a/src/testmod/java/dev/isxander/controlify/test/Test.java +++ b/src/testmod/java/dev/isxander/controlify/test/Test.java @@ -4,17 +4,66 @@ import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.Objects; + +public final class Test { + private final Runnable method; + private final String name; + private boolean hasRan; + + public Test(Runnable method, String name) { + this.method = method; + this.name = name; + } + + public void runTest() { + if (hasRan) + throw new IllegalStateException("Test `" + name + "` cannot run twice."); + + method.run(); + hasRan = true; + } + + public String name() { + return name; + } + + public boolean hasRan() { + return hasRan; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null || obj.getClass() != this.getClass()) return false; + var that = (Test) obj; + return Objects.equals(this.method, that.method) && + Objects.equals(this.name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(method, name); + } + + @Override + public String toString() { + return "Test[" + + "method=" + method + ", " + + "name=" + name + ']'; + } -public record Test(Runnable method, String name) { @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) - public @interface PreLoad { + public @interface Entrypoint { String value(); } @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) - public @interface PostLoad { + public @interface TitleScreen { String value(); } + + }