diff --git a/build.gradle.kts b/build.gradle.kts index 9613ce2f0..6297ffeb5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -150,6 +150,7 @@ manifests { "controlify:default_config", "controlify:controller_type", "controlify:default_binds", + "controlify:trigger_effect", ) )) diff --git a/docs/developers/_meta.json b/docs/developers/_meta.json index 7aa6e7c80..a116b8746 100644 --- a/docs/developers/_meta.json +++ b/docs/developers/_meta.json @@ -3,5 +3,6 @@ "controlify-entrypoint.mdx": "Controlify Entrypoint", "bindings-api.mdx": "Bindings API", "screen-operation-api.mdx": "Screen Operation API", - "guide-api.mdx": "Guides API" + "guide-api.mdx": "Guides API", + "adaptive-trigger-api.mdx": "Adaptive Trigger API" } diff --git a/docs/developers/adaptive-trigger-api.mdx b/docs/developers/adaptive-trigger-api.mdx new file mode 100644 index 000000000..d66d3687e --- /dev/null +++ b/docs/developers/adaptive-trigger-api.mdx @@ -0,0 +1,46 @@ +--- +title: Adaptive Trigger API +--- + +Resource packs should be used for fixed adaptive trigger effects. The Java API is available for effects +that depend on the value stored in an item's data component. + +Register effects during Controlify pre-initialization: + +```java +public final class MyControlifyEntrypoint implements ControlifyEntrypoint { + @Override + public void onControlifyPreInit(PreInitContext context) { + TriggerEffectApi.registerUseItemEffect( + DataComponents.CHARGED_PROJECTILES, + projectiles -> projectiles.isEmpty() + ? new DualsenseTriggerEffect.FeedbackSlope( + (byte) 2, (byte) 9, (byte) 5, (byte) 8 + ) + : new DualsenseTriggerEffect.Weapon( + (byte) 3, (byte) 5, (byte) 1 + ) + ); + } +} +``` + +`TriggerEffectApi` has use-item and swing-item overloads for: + +- A `DataComponentType` and a `Function`, for effects computed from + the component value. +- A `DataComponentType` and a constant `DualsenseTriggerEffect`. +- An `Item` and a constant `DualsenseTriggerEffect`. + +Use `registerUseItemEffect` for the input bound to **Use Item** and `registerSwingItemEffect` for the input +bound to **Attack**. + +## Priority + +[Resource-pack rules](../resource-packs/adaptive-trigger-effects) are always evaluated before Java +registrations, allowing users and pack authors to override mod-provided effects. Among Java registrations, +component functions are checked in registration order before the exact-item fallback. Registering the same +component or item again replaces its previous effect. + +Use a resource-pack `off` rule when an effect should be explicitly disabled. An `off` result returned from +Java also disables the trigger, but is only reached when no resource rule matched. diff --git a/docs/resource-packs/_meta.json b/docs/resource-packs/_meta.json index 88c3d21ff..1fdf22c0b 100644 --- a/docs/resource-packs/_meta.json +++ b/docs/resource-packs/_meta.json @@ -3,5 +3,6 @@ "default-binds.mdx": "Default Binds", "input-glyphs.mdx": "Input Glyphs", "guides.mdx": "Button Guides", - "keyboard-layouts.mdx": "Keyboard Layouts" + "keyboard-layouts.mdx": "Keyboard Layouts", + "adaptive-trigger-effects.mdx": "Adaptive Trigger Effects" } diff --git a/docs/resource-packs/adaptive-trigger-effects.mdx b/docs/resource-packs/adaptive-trigger-effects.mdx new file mode 100644 index 000000000..4619a061a --- /dev/null +++ b/docs/resource-packs/adaptive-trigger-effects.mdx @@ -0,0 +1,96 @@ +--- +title: Adaptive Trigger Effects +--- + +Controlify's DualSense adaptive trigger effects can be changed with a resource pack. Effects are applied +to the controller input bound to either **Use Item** or **Attack**. + +## Creating trigger effect rules + +Create either or both of these files in your resource pack: + +- `assets/controlify/trigger_effect/use_item.json` +- `assets/controlify/trigger_effect/swing_item.json` + +Each file contains an array of rules. A rule selects either one item with `forItem` or one data component +with `forComponent`, then defines the effect to apply. Exactly one selector must be present. + +```json +[ + { + "forItem": "minecraft:bow", + "effect": { + "type": "feedback_slope", + "start_position": 3, + "end_position": 9, + "start_strength": 2, + "end_strength": 8 + } + }, + { + "forComponent": "minecraft:consumable", + "effect": { + "type": "feedback", + "position": 3, + "strength": 1 + } + } +] +``` + +Item rules match that exact item. Component rules match any item stack containing that data component. +Use-item rules check the player's active item and then their offhand item. Swing-item rules check the +main-hand item. + +## Rule priority and stacking + +The files are additive across resource packs. Rules in the highest-priority pack are checked first, followed +by lower-priority packs. Within each file, rules are checked from top to bottom. The first matching rule wins, +whether it uses `forItem` or `forComponent`. + +To remove an effect supplied by Controlify or a lower-priority pack, add a higher-priority rule with an +`off` effect: + +```json +[ + { + "forItem": "minecraft:bow", + "effect": { + "type": "off" + } + } +] +``` + +A matching `off` rule stops evaluation, so rules from lower-priority packs and effects registered by mods +will not be used. + +If a file has invalid JSON, an unknown item or component, an invalid effect, or a rule with both or neither +selector, Controlify logs the error and skips that entire file layer. Other resource packs continue to work. + +## Effect formats + +Trigger positions refer to the DualSense's ten trigger zones, numbered `0` through `9`. Strength and amplitude +values use `0` for off and `8` for maximum. + +| Type | Fields | +|-------------------------------|------------------------------------------------------------------------------------------------------------------------| +| `off` | No additional fields. | +| `feedback` | `position`: 0–9; `strength`: 0–8. Applies constant resistance from that position. | +| `weapon` | `start_position`: 2–7; `end_position`: greater than the start and at most 8; `strength`: 0–8. | +| `vibration` | `position`: 0–9; `amplitude`: 0–8; `frequency`: positive signed-byte frequency in hertz. | +| `feedback_multiple_position` | `strength`: exactly 10 values, each 0–8, corresponding to zones 0–9. | +| `feedback_slope` | `start_position`: 0–8; `end_position`: greater than the start and at most 9; `start_strength` and `end_strength`: 1–8. | +| `vibration_multiple_position` | `frequency`: positive signed-byte frequency in hertz; `amplitude`: exactly 10 values, each 0–8. | + +For example, independent resistance in each trigger zone is written as: + +```json +{ + "type": "feedback_multiple_position", + "strength": [0, 0, 2, 2, 3, 4, 5, 6, 7, 8] +} +``` + +An amplitude or strength of zero disables that zone. Effects whose entire amplitude/strength is zero, or +whose vibration frequency is not positive, behave as `off`. diff --git a/docs/users/mod-comparison.mdx b/docs/users/mod-comparison.mdx index 2ae2ea2a6..da0f89e1a 100644 --- a/docs/users/mod-comparison.mdx +++ b/docs/users/mod-comparison.mdx @@ -6,7 +6,7 @@ title: Mod Comparison quality of the mentioned features, and you should try them all out before making a decision. This table is also not exhaustive, and there are many more features that are not listed here.** -| | Controlify (2.4.0) | [Midnight Controls (1.10.x)](https://github.com/TeamMidnightDust/MidnightControls) | [Controllable (0.23.x)](https://mrcrayfish.com/mods/controllable) | [Controller Support Mod (9.0.0)](https://github.com/Stereowalker/Controller-Support-Mod) | +| | Controlify (3.3.0) | [Midnight Controls (1.10.x)](https://github.com/TeamMidnightDust/MidnightControls) | [Controllable (0.23.x)](https://mrcrayfish.com/mods/controllable) | [Controller Support Mod (9.0.0)](https://github.com/Stereowalker/Controller-Support-Mod) | |--------------------------------------------------|----------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------|------------------------------------------------------------------------------------------| | **Mod Updates** | Frequent mod updates with new features, updates to the latest snapshots | Generally inactive. Updates to new versions quickly, last update Dec '24 | Maintained, last update June '23 | Very slow updates, last update Oct '24 | | **Version support** | Maintains latest snapshot, 1.21.8, 1.21.5, 1.21.4, 1.21.3, 1.21.1 for both Fabric and NeoForge | Maintains 1.21.x | Maintains 1.21.4, 1.21.1 | Maintains 1.21.1 | @@ -32,5 +32,5 @@ exhaustive, and there are many more features that are not listed here.** | **On-screen keyboard** | ✅ Works everywhere, data-driven localised layouts | ⛔ | ⛔ | ✅ | | **Radial action menu** | ✅ Many radial menus for things ranging from quick actions to gamemode switcher to hotbar layout save/load | ✅ | ✅ | ⛔ | | **HD DualSense haptics** | ✅ Extensible by addon mods, only GUI navigation implemented in Controlify | ⛔ | ⛔ | ⛔ | -| **Adaptive DualSense triggers** | ✅ Extensible by addon mods, no functionality built-in | ⛔ | ⛔ | ⛔ | +| **Adaptive DualSense triggers** | ✅ | ⛔ | ⛔ | ⛔ | | **Steam Deck support** (back buttons, gyro, etc) | ✅ Uses CEF debugger, requires Decky Loader | ⛔ | ⛔ | ⛔ | diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java index bede2a635..0e4b64e70 100644 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ b/src/main/java/dev/isxander/controlify/Controlify.java @@ -24,6 +24,7 @@ import dev.isxander.controlify.controller.*; import dev.isxander.controlify.controller.dualsense.BuiltinTriggerEffects; import dev.isxander.controlify.controller.dualsense.TriggerEffectManager; +import dev.isxander.controlify.controller.dualsense.TriggerEffectRegistry; import dev.isxander.controlify.controller.id.ControllerTypeManager; import dev.isxander.controlify.controller.input.ControllerState; import dev.isxander.controlify.controller.input.ControllerStateView; @@ -97,6 +98,7 @@ public class Controlify implements ControlifyApi { private DefaultConfigManager defaultConfigManager; private ControllerTypeManager controllerTypeManager; private KeyboardLayoutManager keyboardLayoutManager; + private TriggerEffectRegistry triggerEffectRegistry; private TriggerEffectManager triggerEffectManager; @@ -142,11 +144,14 @@ public void preInitialiseControlify() { this.defaultConfigManager = new DefaultConfigManager(); this.controllerTypeManager = new ControllerTypeManager(); this.keyboardLayoutManager = new KeyboardLayoutManager(); + this.triggerEffectRegistry = new TriggerEffectRegistry(); + BuiltinTriggerEffects.register(); PlatformClientUtil.registerAssetReloadListener(inputFontMapper); PlatformClientUtil.registerAssetReloadListener(defaultBindManager); PlatformClientUtil.registerAssetReloadListener(defaultConfigManager); PlatformClientUtil.registerAssetReloadListener(controllerTypeManager); PlatformClientUtil.registerAssetReloadListener(keyboardLayoutManager); + PlatformClientUtil.registerAssetReloadListener(triggerEffectRegistry); PlatformClientUtil.registerAssetReloadListener(GuideDomains.IN_GAME); PlatformClientUtil.registerAssetReloadListener(GuideDomains.CONTAINER); @@ -252,8 +257,7 @@ public void initializeControlify() { this.inGameInputHandler = null; // set when the current controller changes this.virtualMouseHandler = new VirtualMouseHandler(); - this.triggerEffectManager = new TriggerEffectManager(this.minecraft); - BuiltinTriggerEffects.register(); + this.triggerEffectManager = new TriggerEffectManager(this.minecraft, this.triggerEffectRegistry); ControlifyEvents.CONTROLLER_CONNECTED.register(event -> this.onControllerAdded( event.controller(), event.hotplugged())); @@ -769,6 +773,10 @@ public TriggerEffectManager triggerEffectManager() { return triggerEffectManager; } + public TriggerEffectRegistry triggerEffectRegistry() { + return triggerEffectRegistry; + } + public Set thisTickBindContexts() { return this.thisTickContexts; } diff --git a/src/main/java/dev/isxander/controlify/api/triggereffect/TriggerEffectApi.java b/src/main/java/dev/isxander/controlify/api/triggereffect/TriggerEffectApi.java index 8ef2cdb5e..1eef74794 100644 --- a/src/main/java/dev/isxander/controlify/api/triggereffect/TriggerEffectApi.java +++ b/src/main/java/dev/isxander/controlify/api/triggereffect/TriggerEffectApi.java @@ -7,7 +7,6 @@ package dev.isxander.controlify.api.triggereffect; import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controller.dualsense.TriggerEffectHolder; import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; import net.minecraft.core.component.DataComponentType; import net.minecraft.world.item.Item; @@ -21,7 +20,7 @@ private TriggerEffectApi() { } public static void registerUseItemEffect(DataComponentType componentType, Function effectFunction) { - Controlify.instance().triggerEffectManager().registerUseItemComponentEffect(componentType, effectFunction); + Controlify.instance().triggerEffectRegistry().registerUseItemComponentEffect(componentType, effectFunction); } public static void registerUseItemEffect(DataComponentType componentType, @NotNull DualsenseTriggerEffect effect) { @@ -29,7 +28,7 @@ public static void registerUseItemEffect(DataComponentType componentType, @No } public static void registerSwingItemEffect(DataComponentType componentType, Function effectFunction) { - Controlify.instance().triggerEffectManager().registerSwingItemComponentEffect(componentType, effectFunction); + Controlify.instance().triggerEffectRegistry().registerSwingItemComponentEffect(componentType, effectFunction); } public static void registerSwingItemEffect(DataComponentType componentType, @NotNull DualsenseTriggerEffect effect) { @@ -37,10 +36,10 @@ public static void registerSwingItemEffect(DataComponentType componentType, @ } public static void registerUseItemEffect(Item item, @NotNull DualsenseTriggerEffect effect) { - ((TriggerEffectHolder) item).controlify$assignUseTriggerEffect(effect); + Controlify.instance().triggerEffectRegistry().registerUseItemEffect(item, effect); } public static void registerSwingItemEffect(Item item, @NotNull DualsenseTriggerEffect effect) { - ((TriggerEffectHolder) item).controlify$assignSwingTriggerEffect(effect); + Controlify.instance().triggerEffectRegistry().registerSwingItemEffect(item, effect); } } diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/BuiltinTriggerEffects.java b/src/main/java/dev/isxander/controlify/controller/dualsense/BuiltinTriggerEffects.java index c6f53079d..10b7593a1 100644 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/BuiltinTriggerEffects.java +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/BuiltinTriggerEffects.java @@ -9,7 +9,6 @@ import dev.isxander.controlify.api.triggereffect.TriggerEffectApi; import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; import net.minecraft.core.component.DataComponents; -import net.minecraft.world.item.Items; public final class BuiltinTriggerEffects { private BuiltinTriggerEffects() { @@ -18,46 +17,11 @@ private BuiltinTriggerEffects() { public static void register() { var quickClick = new DualsenseTriggerEffect.Weapon((byte) 3, (byte) 5, (byte) 1); - TriggerEffectApi.registerUseItemEffect( - Items.BOW, - new DualsenseTriggerEffect.FeedbackSlope((byte) 3, (byte) 9, (byte) 2, (byte) 8) - ); - - TriggerEffectApi.registerSwingItemEffect( - DataComponents.WEAPON, - quickClick - ); - TriggerEffectApi.registerUseItemEffect( DataComponents.CHARGED_PROJECTILES, chargedProjectiles -> chargedProjectiles.isEmpty() ? new DualsenseTriggerEffect.FeedbackSlope((byte) 2, (byte) 9, (byte) 5, (byte) 8) : quickClick ); - - TriggerEffectApi.registerUseItemEffect( - DataComponents.CONSUMABLE, - new DualsenseTriggerEffect.Feedback((byte) 3, (byte) 1) - ); - - TriggerEffectApi.registerUseItemEffect( - DataComponents.BLOCKS_ATTACKS, - new DualsenseTriggerEffect.Feedback((byte) 3, (byte) 3) - ); - - TriggerEffectApi.registerUseItemEffect( - DataComponents.EQUIPPABLE, - quickClick - ); - - TriggerEffectApi.registerUseItemEffect( - DataComponents.KINETIC_WEAPON, - new DualsenseTriggerEffect.Feedback((byte) 3, (byte) 3) - ); - - TriggerEffectApi.registerUseItemEffect( - DataComponents.INSTRUMENT, - new DualsenseTriggerEffect.Feedback((byte) 3, (byte) 3) - ); } } diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectHolder.java b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectHolder.java deleted file mode 100644 index cd56f3705..000000000 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectHolder.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.dualsense; - -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; -import org.jetbrains.annotations.NotNull; - -import java.util.Optional; - -public interface TriggerEffectHolder { - Optional controlify$getUseTriggerEffect(); - - Optional controlify$getSwingTriggerEffect(); - - void controlify$assignUseTriggerEffect(@NotNull DualsenseTriggerEffect effect); - - void controlify$assignSwingTriggerEffect(@NotNull DualsenseTriggerEffect effect); -} diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java index 138f3cf65..3db803b81 100644 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java @@ -13,23 +13,18 @@ import dev.isxander.controlify.utils.MinecraftUtil; import net.minecraft.client.Minecraft; import net.minecraft.client.player.LocalPlayer; -import net.minecraft.core.component.DataComponentType; import net.minecraft.resources.Identifier; import net.minecraft.world.item.ItemStack; import java.util.*; -import java.util.function.Function; public class TriggerEffectManager { private final Minecraft minecraft; + private final TriggerEffectRegistry registry; - private final Map, Function> useItemComponentEffects; - private final Map, Function> swingItemComponentEffects; - - public TriggerEffectManager(Minecraft minecraft) { + public TriggerEffectManager(Minecraft minecraft, TriggerEffectRegistry registry) { this.minecraft = minecraft; - this.useItemComponentEffects = new LinkedHashMap<>(); - this.swingItemComponentEffects = new LinkedHashMap<>(); + this.registry = registry; } public void applyTriggerEffects(ControllerEntity controller, boolean inputSuppressed) { @@ -39,14 +34,6 @@ public void applyTriggerEffects(ControllerEntity controller, boolean inputSuppre }); } - public void registerUseItemComponentEffect(DataComponentType componentType, Function effectFunction) { - this.useItemComponentEffects.put(componentType, (Function) effectFunction); - } - - public void registerSwingItemComponentEffect(DataComponentType componentType, Function effectFunction) { - this.swingItemComponentEffects.put(componentType, (Function) effectFunction); - } - public DualsenseTriggerEffect getCurrentLeftTriggerEffect(ControllerEntity controller, boolean inputSuppressed) { if (!this.shouldUseTriggerEffects(controller, inputSuppressed)) { return DualsenseTriggerEffect.Off.INSTANCE; @@ -122,18 +109,7 @@ public Optional getUseItemTriggerEffect(LocalPlayer play } public Optional getUseItemTriggerEffect(ItemStack stack) { - for (var entry : this.useItemComponentEffects.entrySet()) { - if (stack.has(entry.getKey())) { - Function effectFunction = entry.getValue(); - DualsenseTriggerEffect effect = effectFunction.apply(stack.get(entry.getKey())); - if (effect != null) { - return Optional.of(effect); - } - } - } - - var effectHolder = (TriggerEffectHolder) stack.getItem(); - return effectHolder.controlify$getUseTriggerEffect(); + return this.registry.getUseItemEffect(stack); } public Optional getSwingItemTriggerEffect(LocalPlayer player) { @@ -141,18 +117,7 @@ public Optional getSwingItemTriggerEffect(LocalPlayer pl } public Optional getSwingItemTriggerEffect(ItemStack stack) { - for (var entry : this.swingItemComponentEffects.entrySet()) { - if (stack.has(entry.getKey())) { - Function effectFunction = entry.getValue(); - DualsenseTriggerEffect effect = effectFunction.apply(stack.get(entry.getKey())); - if (effect != null) { - return Optional.of(effect); - } - } - } - - var effectHolder = (TriggerEffectHolder) stack.getItem(); - return effectHolder.controlify$getSwingTriggerEffect(); + return this.registry.getSwingItemEffect(stack); } diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java new file mode 100644 index 000000000..d22d2fc37 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectRegistry.java @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.controller.dualsense; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.mojang.logging.LogUtils; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.utils.CUtil; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +import java.io.BufferedReader; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.Function; + +public class TriggerEffectRegistry implements SimpleControlifyReloadListener { + private static final Logger LOGGER = LogUtils.getLogger(); + + private static final Identifier USE_ITEM_RESOURCE = CUtil.rl("trigger_effect/use_item.json"); + private static final Identifier SWING_ITEM_RESOURCE = CUtil.rl("trigger_effect/swing_item.json"); + private static final Identifier RELOAD_ID = CUtil.rl("trigger_effect"); + + private static final Codec SERIALIZED_RULE_CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.optionalFieldOf("forItem").forGetter(SerializedRule::forItem), + Identifier.CODEC.optionalFieldOf("forComponent").forGetter(SerializedRule::forComponent), + TriggerEffectCodecs.CODEC.fieldOf("effect").forGetter(SerializedRule::effect) + ).apply(instance, SerializedRule::new)).comapFlatMap( + rule -> rule.forItem().isPresent() == rule.forComponent().isPresent() + ? DataResult.error(() -> "Exactly one of 'forItem' and 'forComponent' must be specified") + : DataResult.success(rule), + Function.identity() + ); + + private List useItemResourceRules = List.of(); + private List swingItemResourceRules = List.of(); + + private final Map, Function> useItemComponentEffects = new LinkedHashMap<>(); + private final Map, Function> swingItemComponentEffects = new LinkedHashMap<>(); + private final Map useItemEffects = new LinkedHashMap<>(); + private final Map swingItemEffects = new LinkedHashMap<>(); + + @Override + public CompletableFuture load(ResourceManager manager, Executor executor) { + return CompletableFuture.supplyAsync(() -> new Preparations( + this.loadResourceStack(manager, USE_ITEM_RESOURCE), + this.loadResourceStack(manager, SWING_ITEM_RESOURCE) + ), executor); + } + + private List loadResourceStack(ResourceManager manager, Identifier id) { + List rules = new ArrayList<>(); + + for (Resource resource : manager.getResourceStack(id).reversed()) { + try (BufferedReader reader = resource.openAsReader()) { + JsonElement json = JsonParser.parseReader(reader); + List serializedRules = SERIALIZED_RULE_CODEC.listOf() + .parse(JsonOps.INSTANCE, json) + .getOrThrow(); + + List layerRules = new ArrayList<>(serializedRules.size()); + for (SerializedRule serializedRule : serializedRules) { + layerRules.add(this.resolveRule(serializedRule)); + } + rules.addAll(layerRules); + } catch (Exception e) { + LOGGER.error( + "Failed to load adaptive trigger effects from {} in pack {}; skipping this layer", + id, + resource.sourcePackId(), + e + ); + } + } + + return List.copyOf(rules); + } + + private Rule resolveRule(SerializedRule rule) { + if (rule.forItem().isPresent()) { + Identifier itemId = rule.forItem().orElseThrow(); + if (!BuiltInRegistries.ITEM.containsKey(itemId)) { + throw new IllegalArgumentException("Unknown item '" + itemId + "'"); + } + Item item = BuiltInRegistries.ITEM.getValue(itemId); + return new ItemRule(item, rule.effect()); + } + + Identifier componentId = rule.forComponent().orElseThrow(); + if (!BuiltInRegistries.DATA_COMPONENT_TYPE.containsKey(componentId)) { + throw new IllegalArgumentException("Unknown data component type '" + componentId + "'"); + } + DataComponentType componentType = BuiltInRegistries.DATA_COMPONENT_TYPE.getValue(componentId); + return new ComponentRule(componentType, rule.effect()); + } + + @Override + public CompletableFuture apply(Preparations data, ResourceManager manager, Executor executor) { + return CompletableFuture.runAsync(() -> { + this.useItemResourceRules = data.useItemRules; + this.swingItemResourceRules = data.swingItemRules; + LOGGER.info( + "Loaded {} use-item and {} swing-item adaptive trigger effect rules", + this.useItemResourceRules.size(), + this.swingItemResourceRules.size() + ); + }, executor); + } + + public void registerUseItemComponentEffect( + DataComponentType componentType, + Function effectFunction + ) { + this.useItemComponentEffects.put(componentType, castEffectFunction(effectFunction)); + } + + public void registerSwingItemComponentEffect( + DataComponentType componentType, + Function effectFunction + ) { + this.swingItemComponentEffects.put(componentType, castEffectFunction(effectFunction)); + } + + public void registerUseItemEffect(Item item, DualsenseTriggerEffect effect) { + this.useItemEffects.put(item, effect); + } + + public void registerSwingItemEffect(Item item, DualsenseTriggerEffect effect) { + this.swingItemEffects.put(item, effect); + } + + public Optional getUseItemEffect(ItemStack stack) { + return findEffect( + stack, + this.useItemResourceRules, + this.useItemComponentEffects, + this.useItemEffects + ); + } + + public Optional getSwingItemEffect(ItemStack stack) { + return findEffect( + stack, + this.swingItemResourceRules, + this.swingItemComponentEffects, + this.swingItemEffects + ); + } + + private static Optional findEffect( + ItemStack stack, + List resourceRules, + Map, Function> componentEffects, + Map itemEffects + ) { + for (Rule rule : resourceRules) { + if (rule.matches(stack)) { + return Optional.of(rule.effect()); + } + } + + for (var entry : componentEffects.entrySet()) { + if (stack.has(entry.getKey())) { + @Nullable DualsenseTriggerEffect effect = entry.getValue().apply(stack.get(entry.getKey())); + if (effect != null) { + return Optional.of(effect); + } + } + } + + return Optional.ofNullable(itemEffects.get(stack.getItem())); + } + + @SuppressWarnings("unchecked") + private static Function castEffectFunction( + Function effectFunction + ) { + return (Function) effectFunction; + } + + @Override + public Identifier getReloadId() { + return RELOAD_ID; + } + + private record SerializedRule( + Optional forItem, + Optional forComponent, + DualsenseTriggerEffect effect + ) { + } + + private sealed interface Rule permits ItemRule, ComponentRule { + boolean matches(ItemStack stack); + + DualsenseTriggerEffect effect(); + } + + private record ItemRule(Item item, DualsenseTriggerEffect effect) implements Rule { + @Override + public boolean matches(ItemStack stack) { + return stack.getItem() == this.item; + } + } + + private record ComponentRule( + DataComponentType componentType, + DualsenseTriggerEffect effect + ) implements Rule { + @Override + public boolean matches(ItemStack stack) { + return stack.has(this.componentType); + } + } + + public static final class Preparations { + private final List useItemRules; + private final List swingItemRules; + + private Preparations(List useItemRules, List swingItemRules) { + this.useItemRules = useItemRules; + this.swingItemRules = swingItemRules; + } + } +} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/triggereffect/ItemMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/triggereffect/ItemMixin.java deleted file mode 100644 index 616e27245..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/triggereffect/ItemMixin.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.triggereffect; - -import dev.isxander.controlify.controller.dualsense.TriggerEffectHolder; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; -import net.minecraft.world.item.Item; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; - -import java.util.Optional; - -@Mixin(Item.class) -public class ItemMixin implements TriggerEffectHolder { - @Unique private @Nullable DualsenseTriggerEffect controlify$useEffect = null; - - @Unique private @Nullable DualsenseTriggerEffect controlify$swingEffect = null; - - @Override - public Optional controlify$getUseTriggerEffect() { - return Optional.ofNullable(controlify$useEffect); - } - - @Override - public Optional controlify$getSwingTriggerEffect() { - return Optional.ofNullable(controlify$swingEffect); - } - - @Override - public void controlify$assignUseTriggerEffect(@NotNull DualsenseTriggerEffect effect) { - this.controlify$useEffect = effect; - } - - @Override - public void controlify$assignSwingTriggerEffect(@NotNull DualsenseTriggerEffect effect) { - this.controlify$swingEffect = effect; - } -} diff --git a/src/main/resources/assets/controlify/trigger_effect/swing_item.json b/src/main/resources/assets/controlify/trigger_effect/swing_item.json new file mode 100644 index 000000000..55d109da8 --- /dev/null +++ b/src/main/resources/assets/controlify/trigger_effect/swing_item.json @@ -0,0 +1,11 @@ +[ + { + "forComponent": "minecraft:weapon", + "effect": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + } +] diff --git a/src/main/resources/assets/controlify/trigger_effect/use_item.json b/src/main/resources/assets/controlify/trigger_effect/use_item.json new file mode 100644 index 000000000..8172e5408 --- /dev/null +++ b/src/main/resources/assets/controlify/trigger_effect/use_item.json @@ -0,0 +1,53 @@ +[ + { + "forItem": "minecraft:bow", + "effect": { + "type": "feedback_slope", + "start_position": 3, + "end_position": 9, + "start_strength": 2, + "end_strength": 8 + } + }, + { + "forComponent": "minecraft:consumable", + "effect": { + "type": "feedback", + "position": 3, + "strength": 1 + } + }, + { + "forComponent": "minecraft:blocks_attacks", + "effect": { + "type": "feedback", + "position": 3, + "strength": 3 + } + }, + { + "forComponent": "minecraft:equippable", + "effect": { + "type": "weapon", + "start_position": 3, + "end_position": 5, + "strength": 1 + } + }, + { + "forComponent": "minecraft:kinetic_weapon", + "effect": { + "type": "feedback", + "position": 3, + "strength": 3 + } + }, + { + "forComponent": "minecraft:instrument", + "effect": { + "type": "feedback", + "position": 3, + "strength": 3 + } + } +] diff --git a/src/main/resources/controlify.mixins.json b/src/main/resources/controlify.mixins.json index a7f9418c2..f924794ca 100644 --- a/src/main/resources/controlify.mixins.json +++ b/src/main/resources/controlify.mixins.json @@ -122,8 +122,5 @@ "feature.virtualmouse.snapping.RecipeBookPageAccessor", "feature.virtualmouse.snapping.ScreenMixin", "feature.virtualmouse.snapping.StonecutterScreenMixin" - ], - "mixins": [ - "feature.triggereffect.ItemMixin" ] }