diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierImpl.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierImpl.java new file mode 100644 index 0000000..30eae2c --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierImpl.java @@ -0,0 +1,39 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier; + +import com.mojang.serialization.Codec; + +import net.minecraft.resources.ResourceLocation; + +import com.tiedup.remake.rig.anim.property.AnimationProperty.PlaybackSpeedModifier; +import com.tiedup.remake.rig.anim.types.DynamicAnimation; +import com.tiedup.remake.rig.patch.LivingEntityPatch; + +/** + * Phase 3 D1 — data-driven concrete implementation of + * {@link PlaybackSpeedModifier}. The base functional interface takes five + * parameters ; datapack authors never need all of them, so concrete impls + * ignore the irrelevant ones. + * + *

JSON schema : + *

{@code
+ *   "play_speed_modifier": { "type": "tiedup:constant_factor", "factor": 0.5 }
+ * }
+ * + *

Consumer : {@link com.tiedup.remake.rig.anim.AnimationPlayer#tick} reads + * the modifier and multiplies the current {@code playbackSpeed} by whatever + * the modifier returns each tick — so a modifier that returns {@code 0.5F} + * halves the animation speed. + */ +public interface PlaybackSpeedModifierImpl extends PlaybackSpeedModifier { + + Codec CODEC = PlaybackSpeedModifierRegistry.dispatchCodec(); + + ResourceLocation type(); + + @Override + float modify(DynamicAnimation self, LivingEntityPatch entitypatch, float speed, float prevElapsedTime, float elapsedTime); +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierRegistry.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierRegistry.java new file mode 100644 index 0000000..ac79b8e --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierRegistry.java @@ -0,0 +1,70 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; + +import net.minecraft.resources.ResourceLocation; + +import com.tiedup.remake.rig.anim.modifier.impl.ConstantFactorSpeedModifier; +import com.tiedup.remake.rig.anim.modifier.impl.LinearRampSpeedModifier; + +/** + * Registry of {@link PlaybackSpeedModifierImpl} type ids → codecs. Same + * dispatch pattern as {@link PoseModifierRegistry}. + * + *

Base impls : + *

+ */ +public final class PlaybackSpeedModifierRegistry { + + private static final Map> TYPES = new HashMap<>(); + + private PlaybackSpeedModifierRegistry() {} + + public static void register(ResourceLocation id, Codec codec) { + if (TYPES.containsKey(id)) { + throw new IllegalStateException("Playback speed modifier type " + id + " is already registered."); + } + + TYPES.put(id, codec); + } + + public static Map> types() { + return Collections.unmodifiableMap(TYPES); + } + + public static Codec getCodec(ResourceLocation id) { + return TYPES.get(id); + } + + public static Codec dispatchCodec() { + return ResourceLocation.CODEC.partialDispatch( + "type", + mod -> DataResult.success(mod.type()), + id -> { + Codec codec = TYPES.get(id); + if (codec == null) { + return DataResult.error(() -> "Unknown playback speed modifier type: " + id); + } + return DataResult.success(codec); + } + ); + } + + static { + register(ConstantFactorSpeedModifier.ID, ConstantFactorSpeedModifier.CODEC); + register(LinearRampSpeedModifier.ID, LinearRampSpeedModifier.CODEC); + } +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierImpl.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierImpl.java new file mode 100644 index 0000000..d391ebd --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierImpl.java @@ -0,0 +1,37 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier; + +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.Codec; + +import net.minecraft.resources.ResourceLocation; + +import com.tiedup.remake.rig.anim.property.AnimationProperty.PlaybackTimeModifier; +import com.tiedup.remake.rig.anim.types.DynamicAnimation; +import com.tiedup.remake.rig.patch.LivingEntityPatch; + +/** + * Phase 3 D1 — data-driven concrete implementation of + * {@link PlaybackTimeModifier}. The base interface returns a + * {@code Pair} of {@code (prevElapsed, elapsed)}, typically used + * to loop a sub-section of an animation (set {@code elapsed} back to the + * looppoint when it crosses a threshold). + * + *

JSON schema : + *

{@code
+ *   "elapsed_time_modifier": { "type": "tiedup:loop_section",
+ *                               "loop_start": 0.3, "loop_end": 0.8 }
+ * }
+ */ +public interface PlaybackTimeModifierImpl extends PlaybackTimeModifier { + + Codec CODEC = PlaybackTimeModifierRegistry.dispatchCodec(); + + ResourceLocation type(); + + @Override + Pair modify(DynamicAnimation self, LivingEntityPatch entitypatch, float speed, float prevElapsedTime, float elapsedTime); +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierRegistry.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierRegistry.java new file mode 100644 index 0000000..1a8da04 --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierRegistry.java @@ -0,0 +1,70 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; + +import net.minecraft.resources.ResourceLocation; + +import com.tiedup.remake.rig.anim.modifier.impl.LoopSectionTimeModifier; + +/** + * Registry of {@link PlaybackTimeModifierImpl} type ids → codecs. + * + *

One base impl today : + *

    + *
  • {@code tiedup:loop_section} — rewind elapsed time back to a loop + * start when it crosses a loop end threshold
  • + *
+ * + *

Not a ton of base impls because the common «replay this window» + * behaviour is what 95% of bondage loops need ; more exotic time warps + * (ping-pong, jitter) can be added later without a schema break. + */ +public final class PlaybackTimeModifierRegistry { + + private static final Map> TYPES = new HashMap<>(); + + private PlaybackTimeModifierRegistry() {} + + public static void register(ResourceLocation id, Codec codec) { + if (TYPES.containsKey(id)) { + throw new IllegalStateException("Playback time modifier type " + id + " is already registered."); + } + + TYPES.put(id, codec); + } + + public static Map> types() { + return Collections.unmodifiableMap(TYPES); + } + + public static Codec getCodec(ResourceLocation id) { + return TYPES.get(id); + } + + public static Codec dispatchCodec() { + return ResourceLocation.CODEC.partialDispatch( + "type", + mod -> DataResult.success(mod.type()), + id -> { + Codec codec = TYPES.get(id); + if (codec == null) { + return DataResult.error(() -> "Unknown playback time modifier type: " + id); + } + return DataResult.success(codec); + } + ); + } + + static { + register(LoopSectionTimeModifier.ID, LoopSectionTimeModifier.CODEC); + } +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierImpl.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierImpl.java new file mode 100644 index 0000000..50e4680 --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierImpl.java @@ -0,0 +1,65 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier; + +import com.mojang.serialization.Codec; + +import net.minecraft.resources.ResourceLocation; + +import com.tiedup.remake.rig.anim.Pose; +import com.tiedup.remake.rig.anim.property.AnimationProperty.PoseModifier; +import com.tiedup.remake.rig.anim.types.DynamicAnimation; +import com.tiedup.remake.rig.patch.LivingEntityPatch; + +/** + * Phase 3 D1 — data-driven concrete implementation of the functional + * {@link PoseModifier} interface. + * + *

{@link PoseModifier} is a raw lambda {@code (self, pose, patch, elapsed, + * partialTick) -> void}, which is not serializable on its own. This interface + * extends it with a {@link #type()} identifier and a dispatch codec so that + * datapack authors can write concrete modifiers in JSON : + *

{@code
+ *   "pose_modifier": { "type": "tiedup:joint_rotation_offset",
+ *                       "joint": "upper_arm_left",
+ *                       "pitch": 15.0, "yaw": 0.0, "roll": 0.0 }
+ * }
+ * + *

Unlike {@link com.tiedup.remake.rig.anim.action.AnimationAction} which + * supports lists (a single animation can fire many actions), a single + * {@code pose_modifier} slot accepts exactly one modifier — because + * {@code StaticAnimationProperty.POSE_MODIFIER} is declared as {@code } + * (singular) and the consumer + * ({@link com.tiedup.remake.rig.anim.types.StaticAnimation#modifyPose}) calls + * {@code poseModifier.modify(...)} exactly once. Authors who want composition + * should chain via {@link com.tiedup.remake.rig.anim.modifier.impl.ChainedPoseModifier}. + * + *

All implementations must be side-safe : {@link PoseModifier#modify} is + * invoked in both client render and server logic ticks. Implementations that + * mutate the pose must do so deterministically from the inputs only, no IO or + * world state. + */ +public interface PoseModifierImpl extends PoseModifier { + + /** + * Dispatch codec — reads the {@code "type"} field and delegates to the + * codec registered for that {@link ResourceLocation}. + */ + Codec CODEC = PoseModifierRegistry.dispatchCodec(); + + /** + * The registered type id of this modifier (e.g. + * {@code tiedup:joint_rotation_offset}). + */ + ResourceLocation type(); + + /** + * Forwarded from {@link PoseModifier#modify}. Subinterface so that the + * generic functional contract stays visible at the implementation site + * without Java default-method ambiguity. + */ + @Override + void modify(DynamicAnimation self, Pose pose, LivingEntityPatch entitypatch, float elapsedTime, float partialTick); +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierRegistry.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierRegistry.java new file mode 100644 index 0000000..f75d8db --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierRegistry.java @@ -0,0 +1,82 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; + +import net.minecraft.resources.ResourceLocation; + +import com.tiedup.remake.rig.anim.modifier.impl.ChainedPoseModifier; +import com.tiedup.remake.rig.anim.modifier.impl.JointRotationOffsetModifier; +import com.tiedup.remake.rig.anim.modifier.impl.JointTranslationOffsetModifier; + +/** + * Registry of {@link PoseModifierImpl} type ids → codecs. Mirrors the design + * of {@link com.tiedup.remake.rig.anim.action.AnimationActionRegistry}. + * + *

Three base impls are registered in the static initializer : + *

    + *
  • {@code tiedup:joint_rotation_offset} — nudge a single joint's rotation
  • + *
  • {@code tiedup:joint_translation_offset} — nudge a single joint's translation
  • + *
  • {@code tiedup:chain} — run a list of modifiers in order
  • + *
+ * + *

Registering via the static init means a single reference to + * {@link PoseModifierImpl#CODEC} in a parse path is enough to bootstrap the + * dispatch table, same bootstrap contract as the action registry. + */ +public final class PoseModifierRegistry { + + private static final Map> TYPES = new HashMap<>(); + + private PoseModifierRegistry() {} + + /** + * @throws IllegalStateException if {@code id} is already registered + */ + public static void register(ResourceLocation id, Codec codec) { + if (TYPES.containsKey(id)) { + throw new IllegalStateException("Pose modifier type " + id + " is already registered."); + } + + TYPES.put(id, codec); + } + + /** + * Unmodifiable view of all registered modifier types. + */ + public static Map> types() { + return Collections.unmodifiableMap(TYPES); + } + + public static Codec getCodec(ResourceLocation id) { + return TYPES.get(id); + } + + public static Codec dispatchCodec() { + return ResourceLocation.CODEC.partialDispatch( + "type", + mod -> DataResult.success(mod.type()), + id -> { + Codec codec = TYPES.get(id); + if (codec == null) { + return DataResult.error(() -> "Unknown pose modifier type: " + id); + } + return DataResult.success(codec); + } + ); + } + + static { + register(JointRotationOffsetModifier.ID, JointRotationOffsetModifier.CODEC); + register(JointTranslationOffsetModifier.ID, JointTranslationOffsetModifier.CODEC); + register(ChainedPoseModifier.ID, ChainedPoseModifier.CODEC); + } +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ChainedPoseModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ChainedPoseModifier.java new file mode 100644 index 0000000..4faf4f7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ChainedPoseModifier.java @@ -0,0 +1,97 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier.impl; + +import java.util.List; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.Decoder; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.Encoder; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.resources.ResourceLocation; + +import com.tiedup.remake.rig.TiedUpRigConstants; +import com.tiedup.remake.rig.anim.Pose; +import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl; +import com.tiedup.remake.rig.anim.types.DynamicAnimation; +import com.tiedup.remake.rig.patch.LivingEntityPatch; + +/** + * Composite modifier — runs a list of modifiers sequentially on the same pose. + * Compensates for the singular {@code POSE_MODIFIER} property slot (only one + * modifier can be attached to an animation, but authors often need several + * per-joint nudges). + * + *

JSON schema : + *

{@code
+ *   { "type": "tiedup:chain",
+ *     "modifiers": [
+ *       { "type": "tiedup:joint_rotation_offset",   "joint": "upper_arm_left",  "pitch": 15.0 },
+ *       { "type": "tiedup:joint_translation_offset","joint": "hand_right",      "y":   -0.05 }
+ *     ] }
+ * }
+ * + *

Nested chains are allowed (no cycle detection — an author-written cycle + * would manifest as a {@link StackOverflowError} at tick time, which is + * identifiable and not worth defensive code). Execution order is list order. + */ +public record ChainedPoseModifier( + List modifiers +) implements PoseModifierImpl { + + public static final ResourceLocation ID = TiedUpRigConstants.identifier("chain"); + + /** + * Hand-rolled lazy codec. We cannot use a {@link RecordCodecBuilder} that + * references {@link PoseModifierImpl#CODEC} at static-init time because + * that would create a cycle : + * {@code ChainedPoseModifier.CODEC → PoseModifierImpl.CODEC + * → PoseModifierRegistry. → ChainedPoseModifier.CODEC} + * which leaves ChainedPoseModifier.CODEC null during the registry + * registration call. Instead, we defer the recursive lookup by calling + * {@link PoseModifierImpl#CODEC} inside the encode/decode bodies, which + * run only at (de)serialization time — long after all static inits have + * completed. + */ + public static final Codec CODEC = Codec.of( + new Encoder() { + @Override + public DataResult encode(ChainedPoseModifier input, DynamicOps ops, T prefix) { + return PoseModifierImpl.CODEC.listOf() + .encodeStart(ops, input.modifiers()) + .flatMap(list -> ops.mergeToMap(prefix, ops.createString("modifiers"), list)); + } + }, + new Decoder() { + @Override + public DataResult> decode(DynamicOps ops, T input) { + return ops.getMap(input).flatMap(map -> { + T modifiersField = map.get("modifiers"); + if (modifiersField == null) { + return DataResult.error(() -> "Missing field 'modifiers' in tiedup:chain"); + } + return PoseModifierImpl.CODEC.listOf() + .parse(ops, modifiersField) + .map(list -> com.mojang.datafixers.util.Pair.of(new ChainedPoseModifier(list), input)); + }); + } + } + ); + + @Override + public ResourceLocation type() { + return ID; + } + + @Override + public void modify(DynamicAnimation self, Pose pose, LivingEntityPatch entitypatch, float elapsedTime, float partialTick) { + for (PoseModifierImpl m : this.modifiers) { + m.modify(self, pose, entitypatch, elapsedTime, partialTick); + } + } +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ConstantFactorSpeedModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ConstantFactorSpeedModifier.java new file mode 100644 index 0000000..794271e --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ConstantFactorSpeedModifier.java @@ -0,0 +1,47 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier.impl; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.resources.ResourceLocation; + +import com.tiedup.remake.rig.TiedUpRigConstants; +import com.tiedup.remake.rig.anim.modifier.PlaybackSpeedModifierImpl; +import com.tiedup.remake.rig.anim.types.DynamicAnimation; +import com.tiedup.remake.rig.patch.LivingEntityPatch; + +/** + * Multiply the current playback speed by a fixed factor every tick. + * + *

JSON schema : + *

{@code
+ *   "play_speed_modifier": { "type": "tiedup:constant_factor", "factor": 0.5 }
+ * }
+ * + *

A {@code factor} of {@code 1.0} is a no-op. Negative factors technically + * work (animation plays backward) but the reverse flag in + * {@link com.tiedup.remake.rig.anim.AnimationPlayer} already handles that + * cleanly — prefer that over a negative speed modifier. + */ +public record ConstantFactorSpeedModifier(float factor) implements PlaybackSpeedModifierImpl { + + public static final ResourceLocation ID = TiedUpRigConstants.identifier("constant_factor"); + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Codec.FLOAT.fieldOf("factor").forGetter(ConstantFactorSpeedModifier::factor) + ).apply(i, ConstantFactorSpeedModifier::new)); + + @Override + public ResourceLocation type() { + return ID; + } + + @Override + public float modify(DynamicAnimation self, LivingEntityPatch entitypatch, float speed, float prevElapsedTime, float elapsedTime) { + return speed * this.factor; + } +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointRotationOffsetModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointRotationOffsetModifier.java new file mode 100644 index 0000000..852d939 --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointRotationOffsetModifier.java @@ -0,0 +1,79 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier.impl; + +import org.joml.Quaternionf; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; + +import com.tiedup.remake.rig.TiedUpRigConstants; +import com.tiedup.remake.rig.anim.Pose; +import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl; +import com.tiedup.remake.rig.anim.types.DynamicAnimation; +import com.tiedup.remake.rig.armature.JointTransform; +import com.tiedup.remake.rig.patch.LivingEntityPatch; + +/** + * Rotate a single named joint by a fixed Euler offset (degrees) at every tick + * of the animation. Bondage pipeline use-case : lock a restrained arm joint to + * a tighter angle than the authored animation provides. + * + *

JSON schema : + *

{@code
+ *   { "type": "tiedup:joint_rotation_offset",
+ *     "joint": "upper_arm_left",
+ *     "pitch": 15.0,
+ *     "yaw": 0.0,
+ *     "roll": 0.0 }
+ * }
+ * + *

Angles default to zero if omitted — a {@code joint_rotation_offset} with + * all three zero is a no-op (author error, logged nowhere — by design, since + * it's a cheap operation and a valid edge case during iteration). + * + *

If the joint is absent from the pose, this modifier is a silent no-op : + * the authored animation simply doesn't touch that joint on this frame. This + * matches the semantics of {@link Pose#orElseEmpty} used elsewhere. + */ +public record JointRotationOffsetModifier( + String joint, + float pitch, + float yaw, + float roll +) implements PoseModifierImpl { + + public static final ResourceLocation ID = TiedUpRigConstants.identifier("joint_rotation_offset"); + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Codec.STRING.fieldOf("joint").forGetter(JointRotationOffsetModifier::joint), + Codec.FLOAT.optionalFieldOf("pitch", 0.0F).forGetter(JointRotationOffsetModifier::pitch), + Codec.FLOAT.optionalFieldOf("yaw", 0.0F).forGetter(JointRotationOffsetModifier::yaw), + Codec.FLOAT.optionalFieldOf("roll", 0.0F).forGetter(JointRotationOffsetModifier::roll) + ).apply(i, JointRotationOffsetModifier::new)); + + @Override + public ResourceLocation type() { + return ID; + } + + @Override + public void modify(DynamicAnimation self, Pose pose, LivingEntityPatch entitypatch, float elapsedTime, float partialTick) { + if (!pose.hasTransform(this.joint)) { + return; + } + + JointTransform jt = pose.get(this.joint); + Quaternionf offset = new Quaternionf().rotationXYZ( + this.pitch * Mth.DEG_TO_RAD, + this.yaw * Mth.DEG_TO_RAD, + this.roll * Mth.DEG_TO_RAD + ); + jt.rotation().mul(offset); + } +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointTranslationOffsetModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointTranslationOffsetModifier.java new file mode 100644 index 0000000..570f0fa --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointTranslationOffsetModifier.java @@ -0,0 +1,64 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier.impl; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.resources.ResourceLocation; + +import com.tiedup.remake.rig.TiedUpRigConstants; +import com.tiedup.remake.rig.anim.Pose; +import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl; +import com.tiedup.remake.rig.anim.types.DynamicAnimation; +import com.tiedup.remake.rig.armature.JointTransform; +import com.tiedup.remake.rig.patch.LivingEntityPatch; + +/** + * Shift a single named joint by a fixed translation offset at every tick of + * the animation. Use-case : pull a constrained wrist joint closer to the + * furniture anchor than the authored animation puts it. + * + *

JSON schema : + *

{@code
+ *   { "type": "tiedup:joint_translation_offset",
+ *     "joint": "hand_right",
+ *     "x": 0.0, "y": -0.05, "z": 0.02 }
+ * }
+ * + *

All three axes default to zero — a fully-zero offset is a no-op. + * Missing joint is a silent no-op (same contract as the rotation variant). + */ +public record JointTranslationOffsetModifier( + String joint, + float x, + float y, + float z +) implements PoseModifierImpl { + + public static final ResourceLocation ID = TiedUpRigConstants.identifier("joint_translation_offset"); + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Codec.STRING.fieldOf("joint").forGetter(JointTranslationOffsetModifier::joint), + Codec.FLOAT.optionalFieldOf("x", 0.0F).forGetter(JointTranslationOffsetModifier::x), + Codec.FLOAT.optionalFieldOf("y", 0.0F).forGetter(JointTranslationOffsetModifier::y), + Codec.FLOAT.optionalFieldOf("z", 0.0F).forGetter(JointTranslationOffsetModifier::z) + ).apply(i, JointTranslationOffsetModifier::new)); + + @Override + public ResourceLocation type() { + return ID; + } + + @Override + public void modify(DynamicAnimation self, Pose pose, LivingEntityPatch entitypatch, float elapsedTime, float partialTick) { + if (!pose.hasTransform(this.joint)) { + return; + } + + JointTransform jt = pose.get(this.joint); + jt.translation().add(this.x, this.y, this.z); + } +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LinearRampSpeedModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LinearRampSpeedModifier.java new file mode 100644 index 0000000..65f7fea --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LinearRampSpeedModifier.java @@ -0,0 +1,72 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier.impl; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.util.Mth; + +import com.tiedup.remake.rig.TiedUpRigConstants; +import com.tiedup.remake.rig.anim.modifier.PlaybackSpeedModifierImpl; +import com.tiedup.remake.rig.anim.types.DynamicAnimation; +import com.tiedup.remake.rig.patch.LivingEntityPatch; + +/** + * Ramp the playback speed factor linearly from {@code from} to {@code to} over + * the time window {@code [startTime, endTime]} (in seconds of animation + * elapsed). Outside the window, clamps to the boundary value. + * + *

JSON schema : + *

{@code
+ *   "play_speed_modifier": { "type": "tiedup:linear_ramp",
+ *                             "from": 0.2, "to": 1.0,
+ *                             "start_time": 0.0, "end_time": 0.5 }
+ * }
+ * + *

Use-case : hesitant-start animations (slow initial frames, then ramp to + * full speed for the payoff). The modifier's output is multiplied against the + * base speed by {@link com.tiedup.remake.rig.anim.AnimationPlayer#tick}, so + * {@code from=1.0, to=1.0} is a no-op. + * + *

{@code end_time <= start_time} degenerates to returning {@code to} for + * all {@code elapsedTime >= start_time} (clamped path above saturation). + */ +public record LinearRampSpeedModifier( + float from, + float to, + float startTime, + float endTime +) implements PlaybackSpeedModifierImpl { + + public static final ResourceLocation ID = TiedUpRigConstants.identifier("linear_ramp"); + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Codec.FLOAT.fieldOf("from").forGetter(LinearRampSpeedModifier::from), + Codec.FLOAT.fieldOf("to").forGetter(LinearRampSpeedModifier::to), + Codec.FLOAT.optionalFieldOf("start_time", 0.0F).forGetter(LinearRampSpeedModifier::startTime), + Codec.FLOAT.optionalFieldOf("end_time", 1.0F).forGetter(LinearRampSpeedModifier::endTime) + ).apply(i, LinearRampSpeedModifier::new)); + + @Override + public ResourceLocation type() { + return ID; + } + + @Override + public float modify(DynamicAnimation self, LivingEntityPatch entitypatch, float speed, float prevElapsedTime, float elapsedTime) { + float factor; + if (elapsedTime <= this.startTime) { + factor = this.from; + } else if (elapsedTime >= this.endTime || this.endTime <= this.startTime) { + factor = this.to; + } else { + float progress = (elapsedTime - this.startTime) / (this.endTime - this.startTime); + factor = Mth.lerp(progress, this.from, this.to); + } + return speed * factor; + } +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LoopSectionTimeModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LoopSectionTimeModifier.java new file mode 100644 index 0000000..bd87618 --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LoopSectionTimeModifier.java @@ -0,0 +1,68 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier.impl; + +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.resources.ResourceLocation; + +import com.tiedup.remake.rig.TiedUpRigConstants; +import com.tiedup.remake.rig.anim.modifier.PlaybackTimeModifierImpl; +import com.tiedup.remake.rig.anim.types.DynamicAnimation; +import com.tiedup.remake.rig.patch.LivingEntityPatch; + +/** + * Rewind the animation playhead back to {@code loopStart} every time it + * crosses {@code loopEnd}. Matches the «sustain loop» pattern used e.g. for + * breathing idles inside a rest phase — the intro plays once, a middle section + * loops, and the outro is triggered by a separate state transition (not this + * modifier). + * + *

JSON schema : + *

{@code
+ *   "elapsed_time_modifier": { "type": "tiedup:loop_section",
+ *                               "loop_start": 0.3, "loop_end": 0.8 }
+ * }
+ * + *

This modifier is a pure function of {@code elapsedTime} — it does not + * mutate the animation. The {@code prevElapsedTime} is rewound symmetrically + * so event triggers using {@code [prev, elapsed]} windows still fire cleanly + * after the rewind. + * + *

Invariant : {@code loopStart < loopEnd}. Violating this yields undefined + * behaviour (probably a NaN or an instant loop) — no defensive check because + * the author would notice immediately on first playback. + */ +public record LoopSectionTimeModifier( + float loopStart, + float loopEnd +) implements PlaybackTimeModifierImpl { + + public static final ResourceLocation ID = TiedUpRigConstants.identifier("loop_section"); + + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Codec.FLOAT.fieldOf("loop_start").forGetter(LoopSectionTimeModifier::loopStart), + Codec.FLOAT.fieldOf("loop_end").forGetter(LoopSectionTimeModifier::loopEnd) + ).apply(i, LoopSectionTimeModifier::new)); + + @Override + public ResourceLocation type() { + return ID; + } + + @Override + public Pair modify(DynamicAnimation self, LivingEntityPatch entitypatch, float speed, float prevElapsedTime, float elapsedTime) { + if (elapsedTime >= this.loopEnd) { + float overshoot = elapsedTime - this.loopEnd; + float sectionLen = this.loopEnd - this.loopStart; + float wrapped = this.loopStart + (overshoot % sectionLen); + float prevWrapped = prevElapsedTime - (elapsedTime - wrapped); + return Pair.of(prevWrapped, wrapped); + } + return Pair.of(prevElapsedTime, elapsedTime); + } +} diff --git a/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java b/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java index b5c439e..4016497 100644 --- a/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java +++ b/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java @@ -17,6 +17,7 @@ import com.google.common.collect.Maps; import com.google.gson.JsonElement; import com.mojang.datafixers.util.Pair; import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; import com.mojang.serialization.JsonOps; import net.minecraft.resources.ResourceLocation; @@ -29,6 +30,9 @@ import com.tiedup.remake.rig.anim.LivingMotion; import com.tiedup.remake.rig.anim.Pose; import com.tiedup.remake.rig.anim.TransformSheet; import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents; +import com.tiedup.remake.rig.anim.modifier.PlaybackSpeedModifierImpl; +import com.tiedup.remake.rig.anim.modifier.PlaybackTimeModifierImpl; +import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl; import com.tiedup.remake.rig.anim.property.AnimationEvent.SimpleEvent; import com.tiedup.remake.rig.anim.property.MoveCoordFunctions.MoveCoordGetter; import com.tiedup.remake.rig.anim.property.MoveCoordFunctions.MoveCoordSetter; @@ -46,6 +50,53 @@ import com.tiedup.remake.rig.patch.item.CapabilityItem; public abstract class AnimationProperty { private static final Map> SERIALIZABLE_ANIMATION_PROPERTY_KEYS = Maps.newHashMap(); + + /** + * Phase 3 D1 — {@link LivingMotion} codec. {@code LivingMotion} is an + * {@link com.tiedup.remake.rig.util.ExtendableEnum} so its string form is + * the {@link #toString()} lowercased ; we deserialize via + * {@code ENUM_MANAGER.getOrThrow(String)} which throws if unknown. + */ + public static final Codec LIVING_MOTION_CODEC = Codec.STRING.flatXmap( + name -> { + try { + return DataResult.success(LivingMotion.ENUM_MANAGER.getOrThrow(name)); + } catch (java.util.NoSuchElementException e) { + return DataResult.error(() -> "Unknown living motion: " + name); + } + }, + motion -> DataResult.success(motion.toString().toLowerCase()) + ); + + /** + * Phase 3 D1 — {@link TimePairList} codec. Authored as a flat list of + * floats that must have an even length (pairs of {@code begin, end}). + * Odd-length lists surface as a codec error (logged + property skipped). + * + *

JSON shape : {@code [0.0, 0.3, 0.6, 0.9]} → two pairs, {@code [0,0.3]} + * and {@code [0.6,0.9]}. We go through a float[] because {@code + * TimePairList.create} is varargs. Decoding via + * {@link TimePairList#create(float...)} surfaces the odd-count invariant + * via {@link IllegalArgumentException} — wrapped into a {@link DataResult} + * error. + */ + public static final Codec TIME_PAIR_LIST_CODEC = Codec.FLOAT.listOf().flatXmap( + list -> { + if ((list.size() & 1) != 0) { + return DataResult.error(() -> "TimePairList must have an even number of floats, got " + list.size()); + } + float[] arr = new float[list.size()]; + for (int i = 0; i < arr.length; i++) { + arr[i] = list.get(i); + } + return DataResult.success(TimePairList.create(arr)); + }, + // Encode back : no public accessor on TimePairList's internal pairs, + // and the property pipeline is read-only from JSON (never re-serialized + // to disk) — we therefore refuse encoding. If round-trip becomes a + // requirement, expose TimePairList#asFloatArray() first. + tpl -> DataResult.error(() -> "TimePairList encoding is not supported (read-only datapack property)") + ); @SuppressWarnings("unchecked") public static AnimationProperty getSerializableProperty(String name) { @@ -135,38 +186,116 @@ public abstract class AnimationProperty { /** * An event triggered when entity changes an item in hand. - * Retenu comme hook datapack : un event écrit en JSON par un datapack tiers peut s'abonner - * au changement d'item porté pour déclencher une réaction (voir Phase 3 data-driven anims). + * + *

Phase 3 D1 — Category C, SKIPPED. The item-change event is a + * combat/equipment hook from Epic Fight upstream ; no bondage pipeline + * consumer reads this property today. Re-enable (register a codec) when + * weapon/equipment reactive animations are reintroduced. */ public static final StaticAnimationProperty>> ON_ITEM_CHANGE_EVENT = new StaticAnimationProperty>> (); - + /** * You can modify the playback speed of the animation. + * + *

Phase 3 D1 — serializable via the + * {@link PlaybackSpeedModifierImpl#CODEC} dispatch codec (Category B). + * JSON example : + *

{@code
+		 *   "play_speed_modifier": { "type": "tiedup:constant_factor", "factor": 0.5 }
+		 * }
+ * The upcast {@code PlaybackSpeedModifierImpl → PlaybackSpeedModifier} + * is implicit (sub-interface) so the property stays typed as the wider + * functional type for backward-compat with existing consumers in + * {@link com.tiedup.remake.rig.anim.AnimationPlayer#tick} and + * {@link com.tiedup.remake.rig.anim.client.AnimationSubFileReader}. */ - public static final StaticAnimationProperty PLAY_SPEED_MODIFIER = new StaticAnimationProperty (); + public static final StaticAnimationProperty PLAY_SPEED_MODIFIER = new StaticAnimationProperty ( + "play_speed_modifier", + PlaybackSpeedModifierImpl.CODEC.xmap( + impl -> (PlaybackSpeedModifier) impl, + base -> { + if (base instanceof PlaybackSpeedModifierImpl impl) return impl; + throw new IllegalStateException("PlaybackSpeedModifier value is not a serializable impl: " + base); + } + ) + ); + + /** + * You can modify the elapsed playback time of the animation. + * + *

Phase 3 D1 — serializable via the + * {@link PlaybackTimeModifierImpl#CODEC} dispatch codec (Category B). + * JSON example : + *

{@code
+		 *   "elapsed_time_modifier": { "type": "tiedup:loop_section",
+		 *                               "loop_start": 0.3, "loop_end": 0.8 }
+		 * }
+ */ + public static final StaticAnimationProperty ELAPSED_TIME_MODIFIER = new StaticAnimationProperty ( + "elapsed_time_modifier", + PlaybackTimeModifierImpl.CODEC.xmap( + impl -> (PlaybackTimeModifier) impl, + base -> { + if (base instanceof PlaybackTimeModifierImpl impl) return impl; + throw new IllegalStateException("PlaybackTimeModifier value is not a serializable impl: " + base); + } + ) + ); + + /** + * This property will be called both in client and server when modifying the pose. + * + *

Phase 3 D1 — serializable via the + * {@link PoseModifierImpl#CODEC} dispatch codec (Category B). Key + * artist unlock for bondage : per-joint nudges can be authored + * from JSON : + *

{@code
+		 *   "pose_modifier": { "type": "tiedup:chain",
+		 *                       "modifiers": [
+		 *                         { "type": "tiedup:joint_rotation_offset",    "joint": "upper_arm_left",  "pitch": 15.0 },
+		 *                         { "type": "tiedup:joint_translation_offset", "joint": "hand_right",      "y":    -0.05 }
+		 *                       ] }
+		 * }
+ */ + public static final StaticAnimationProperty POSE_MODIFIER = new StaticAnimationProperty ( + "pose_modifier", + PoseModifierImpl.CODEC.xmap( + impl -> (PoseModifier) impl, + base -> { + if (base instanceof PoseModifierImpl impl) return impl; + throw new IllegalStateException("PoseModifier value is not a serializable impl: " + base); + } + ) + ); + + /** + * Fix the head rotation to the player's body rotation. + * + *

Phase 3 D1 — Category A, trivial boolean codec. + */ + public static final StaticAnimationProperty FIXED_HEAD_ROTATION = new StaticAnimationProperty ( + "fixed_head_rotation", + Codec.BOOL + ); /** - * You can modify the playback speed of the animation. - */ - public static final StaticAnimationProperty ELAPSED_TIME_MODIFIER = new StaticAnimationProperty (); - - /** - * This property will be called both in client and server when modifying the pose - */ - public static final StaticAnimationProperty POSE_MODIFIER = new StaticAnimationProperty (); - - /** - * Fix the head rotation to the player's body rotation - */ - public static final StaticAnimationProperty FIXED_HEAD_ROTATION = new StaticAnimationProperty (); - - /** - * Defines static animations as link animation when the animation is followed by a specific animation + * Defines static animations as link animation when the animation is followed by a specific animation. + * + *

Phase 3 D1 — Category D, SKIPPED. The map value is an + * {@link AnimationAccessor} which is an internal registry handle (not a + * data-describable object) ; resolving accessors from ids requires the + * {@link com.tiedup.remake.rig.anim.AnimationManager} to already be + * populated, which is only true after full resource-pack load. The + * existing sub-file reader path handles transitions correctly — moving + * the parse here would create a chicken-and-egg bootstrapping issue. */ public static final StaticAnimationProperty>> TRANSITION_ANIMATIONS_FROM = new StaticAnimationProperty>> (); - + /** - * Defines static animations as link animation when the animation is following a specific animation + * Defines static animations as link animation when the animation is following a specific animation. + * + *

Phase 3 D1 — Category D, SKIPPED (same reason as + * {@link #TRANSITION_ANIMATIONS_FROM}). */ public static final StaticAnimationProperty>> TRANSITION_ANIMATIONS_TO = new StaticAnimationProperty>> (); @@ -176,19 +305,38 @@ public abstract class AnimationProperty { public static final StaticAnimationProperty NO_PHYSICS = new StaticAnimationProperty ("no_physics", Codec.BOOL); /** - * Inverse kinematics information + * Inverse kinematics information. + * + *

Phase 3 D1 — Category D, SKIPPED. {@link InverseKinematicsDefinition} + * holds complex baked state (joint chains, pole targets, constraint + * weights) that is authored via a dedicated IK definition file, not + * via the animation properties block. See the sub-file reader in + * {@link com.tiedup.remake.rig.anim.client.AnimationSubFileReader}. */ public static final StaticAnimationProperty> IK_DEFINITION = new StaticAnimationProperty> (); - + /** - * This property automatically baked when animation is loaded + * This property automatically baked when animation is loaded. + * + *

Phase 3 D1 — Category D, SKIPPED. Never authored in JSON : this + * slot is populated at load time by + * {@link com.tiedup.remake.rig.anim.types.StaticAnimation#loadAnimation} + * from {@link #IK_DEFINITION} which is then cleared. */ public static final StaticAnimationProperty> BAKED_IK_DEFINITION = new StaticAnimationProperty> (); - + /** - * This property reset the entity's living motion + * This property reset the entity's living motion. + * + *

Phase 3 D1 — Category A, codec uses the shared + * {@link AnimationProperty#LIVING_MOTION_CODEC} which resolves against + * {@link LivingMotion#ENUM_MANAGER}. JSON example : + *

{@code "reset_living_motion": "idle"}
*/ - public static final StaticAnimationProperty RESET_LIVING_MOTION = new StaticAnimationProperty (); + public static final StaticAnimationProperty RESET_LIVING_MOTION = new StaticAnimationProperty ( + "reset_living_motion", + LIVING_MOTION_CODEC + ); } public static class ActionAnimationProperty extends StaticAnimationProperty { @@ -218,11 +366,25 @@ public abstract class AnimationProperty { /** * This property determines the time of entity not affected by gravity. + * + *

Phase 3 D1 — Category A, codec uses the shared + * {@link AnimationProperty#TIME_PAIR_LIST_CODEC}. JSON example : + *

{@code "no_gravity_time": [0.1, 0.4, 0.6, 0.9]}
+ * (two no-gravity windows : [0.1..0.4] and [0.6..0.9]). */ - public static final ActionAnimationProperty NO_GRAVITY_TIME = new ActionAnimationProperty (); - + public static final ActionAnimationProperty NO_GRAVITY_TIME = new ActionAnimationProperty ( + "no_gravity_time", + TIME_PAIR_LIST_CODEC + ); + /** - * Coord of action animation + * Coord of action animation. + * + *

Phase 3 D1 — Category C, SKIPPED. {@link TransformSheet} is + * populated from the animation's baked keyframes by + * {@link com.tiedup.remake.rig.asset.JsonAssetLoader} at load time + * (not authored in the properties block). Exposing a codec here would + * double-write an already-computed value. */ public static final ActionAnimationProperty COORD = new ActionAnimationProperty (); @@ -233,21 +395,38 @@ public abstract class AnimationProperty { /** * You can specify the coord movement time in action animation. Must be registered in order of time. + * + *

Phase 3 D1 — Category A, codec uses + * {@link AnimationProperty#TIME_PAIR_LIST_CODEC}. */ - public static final ActionAnimationProperty MOVE_TIME = new ActionAnimationProperty (); - + public static final ActionAnimationProperty MOVE_TIME = new ActionAnimationProperty ( + "move_time", + TIME_PAIR_LIST_CODEC + ); + /** * Set the dynamic coordinates of {@link ActionAnimation}. Called before creation of {@link LinkAnimation}. + * + *

Phase 3 D1 — Category C, SKIPPED. {@link MoveCoordSetter} is a + * combat/action-movement hook from Epic Fight upstream ; the bondage + * pipeline never authors these at the datapack level (coord work is + * done via coord joints and the sub-file reader). */ public static final ActionAnimationProperty COORD_SET_BEGIN = new ActionAnimationProperty (); - + /** * Set the dynamic coordinates of {@link ActionAnimation}. + * + *

Phase 3 D1 — Category C, SKIPPED (same reason as + * {@link #COORD_SET_BEGIN}). */ public static final ActionAnimationProperty COORD_SET_TICK = new ActionAnimationProperty (); - + /** * Set the coordinates of action animation. + * + *

Phase 3 D1 — Category C, SKIPPED (same reason as + * {@link #COORD_SET_BEGIN}). */ public static final ActionAnimationProperty COORD_GET = new ActionAnimationProperty (); @@ -268,8 +447,17 @@ public abstract class AnimationProperty { /** * This property determines the update time of {@link ActionAnimationProperty#COORD_SET_TICK}. If the current time out of the bound it uses {@link MoveCoordFunctions#RAW_COORD and MoveCoordFunctions#DIFF_FROM_PREV_COORD}} + * + *

Phase 3 D1 — Category A, codec uses + * {@link AnimationProperty#TIME_PAIR_LIST_CODEC}. Still serialized + * despite being tied to {@link #COORD_SET_TICK} (Category C) because + * the underlying datatype is data and authoring the time windows + * without the setter is still legal (it degrades to a no-op, cheap). */ - public static final ActionAnimationProperty COORD_UPDATE_TIME = new ActionAnimationProperty (); + public static final ActionAnimationProperty COORD_UPDATE_TIME = new ActionAnimationProperty ( + "coord_update_time", + TIME_PAIR_LIST_CODEC + ); /** * This property determines if it reset the player basic attack combo counter or not. @@ -278,29 +466,48 @@ public abstract class AnimationProperty { public static final ActionAnimationProperty RESET_PLAYER_COMBO_COUNTER = new ActionAnimationProperty ("reset_combo_attack_counter", Codec.BOOL); /** - * Provide destination of action animation {@link MoveCoordFunctions} + * Provide destination of action animation {@link MoveCoordFunctions}. + * + *

Phase 3 D1 — Category C, SKIPPED. Combat-movement hook ; the + * bondage pipeline would need a whole new destination-provider + * registry to expose this via JSON, and there is no artist use-case + * today. */ public static final ActionAnimationProperty DEST_LOCATION_PROVIDER = new ActionAnimationProperty (); - + /** - * Provide y rotation of entity {@link MoveCoordFunctions} + * Provide y rotation of entity {@link MoveCoordFunctions}. + * + *

Phase 3 D1 — Category C, SKIPPED (combat-rotation hook). */ public static final ActionAnimationProperty ENTITY_YROT_PROVIDER = new ActionAnimationProperty (); - + /** - * Provide y rotation of tracing coord {@link MoveCoordFunctions} + * Provide y rotation of tracing coord {@link MoveCoordFunctions}. + * + *

Phase 3 D1 — Category C, SKIPPED (combat-rotation hook). */ public static final ActionAnimationProperty DEST_COORD_YROT_PROVIDER = new ActionAnimationProperty (); - + /** - * Decides the index of start key frame for coord transform, See also with {@link MoveCoordFunctions#TRACE_ORIGIN_AS_DESTINATION} + * Decides the index of start key frame for coord transform, See also with {@link MoveCoordFunctions#TRACE_ORIGIN_AS_DESTINATION}. + * + *

Phase 3 D1 — Category A, trivial int codec. */ - public static final ActionAnimationProperty COORD_START_KEYFRAME_INDEX = new ActionAnimationProperty (); - + public static final ActionAnimationProperty COORD_START_KEYFRAME_INDEX = new ActionAnimationProperty ( + "coord_start_keyframe_index", + Codec.INT + ); + /** - * Decides the index of destination key frame for coord transform, See also with {@link MoveCoordFunctions#TRACE_ORIGIN_AS_DESTINATION} + * Decides the index of destination key frame for coord transform, See also with {@link MoveCoordFunctions#TRACE_ORIGIN_AS_DESTINATION}. + * + *

Phase 3 D1 — Category A, trivial int codec. */ - public static final ActionAnimationProperty COORD_DEST_KEYFRAME_INDEX = new ActionAnimationProperty (); + public static final ActionAnimationProperty COORD_DEST_KEYFRAME_INDEX = new ActionAnimationProperty ( + "coord_dest_keyframe_index", + Codec.INT + ); /** * Determines if an entity should look where a camera is looking at the beginning of an animation (player only) @@ -343,15 +550,30 @@ public abstract class AnimationProperty { public static final AttackAnimationProperty REACH = new AttackAnimationProperty ("reach", Codec.FLOAT); } + /** + * Combat-phase properties for attack animations. + * + *

Phase 3 D1 — entire class body is Category C (combat-only, EF + * legacy). The constructor no longer registers properties in the shared + * dispatch map (see the commented {@code super(...)} call below) — this + * was deliberately neutered in a prior audit because the bondage pipeline + * never reads {@link AttackPhaseProperty} values. The four + * {@link ValueModifier} constants kept their names purely for static + * reference by surviving combat-adjacent code paths ; the sound/tag/ + * location properties are forever null-codec. Do not add codecs here + * until a combat feature is reintroduced (see V3-REW-11+ in the + * roadmap) — instead, resurrect the commented {@code super} call first + * so the dispatch map picks them up consistently. + */ public static class AttackPhaseProperty { public AttackPhaseProperty(String rl, @Nullable Codec codecs) { //super(rl, codecs); } - + public AttackPhaseProperty() { //this(null, null); } - + public static final AttackPhaseProperty MAX_STRIKES_MODIFIER = new AttackPhaseProperty ("max_strikes", ValueModifier.CODEC); public static final AttackPhaseProperty DAMAGE_MODIFIER = new AttackPhaseProperty ("damage", ValueModifier.CODEC); public static final AttackPhaseProperty ARMOR_NEGATION_MODIFIER = new AttackPhaseProperty ("armor_negation", ValueModifier.CODEC); diff --git a/src/test/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierRegistryTest.java b/src/test/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierRegistryTest.java new file mode 100644 index 0000000..7c2d026 --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierRegistryTest.java @@ -0,0 +1,109 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; + +import net.minecraft.resources.ResourceLocation; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.tiedup.remake.rig.anim.modifier.impl.ConstantFactorSpeedModifier; +import com.tiedup.remake.rig.anim.modifier.impl.LinearRampSpeedModifier; + +class PlaybackSpeedModifierRegistryTest { + + @BeforeAll + static void forceClinit() { + assertNotNull(ConstantFactorSpeedModifier.ID); + assertNotNull(LinearRampSpeedModifier.ID); + assertNotNull(PlaybackSpeedModifierImpl.CODEC); + } + + @Test + void baseImpls_registered() { + assertNotNull(PlaybackSpeedModifierRegistry.getCodec(ConstantFactorSpeedModifier.ID)); + assertNotNull(PlaybackSpeedModifierRegistry.getCodec(LinearRampSpeedModifier.ID)); + } + + @Test + void getCodec_unknownType_returnsNull() { + assertNull(PlaybackSpeedModifierRegistry.getCodec(new ResourceLocation("foo", "bar"))); + } + + @Test + void register_duplicateId_throws() { + assertThrows(IllegalStateException.class, () -> + PlaybackSpeedModifierRegistry.register(ConstantFactorSpeedModifier.ID, ConstantFactorSpeedModifier.CODEC) + ); + } + + @Test + void constantFactor_roundtrip() { + ConstantFactorSpeedModifier original = new ConstantFactorSpeedModifier(0.5F); + + DataResult encoded = PlaybackSpeedModifierImpl.CODEC.encodeStart(JsonOps.INSTANCE, original); + assertTrue(encoded.result().isPresent(), "encode error : " + encoded.error()); + JsonObject json = encoded.result().get().getAsJsonObject(); + assertEquals("tiedup:constant_factor", json.get("type").getAsString()); + + DataResult decoded = PlaybackSpeedModifierImpl.CODEC.parse(JsonOps.INSTANCE, json); + assertTrue(decoded.result().isPresent()); + assertEquals(original, decoded.result().get()); + } + + @Test + void constantFactor_halvesSpeed() { + ConstantFactorSpeedModifier mod = new ConstantFactorSpeedModifier(0.5F); + assertEquals(1.0F, mod.modify(null, null, 2.0F, 0F, 0F), 1e-5F); + } + + @Test + void linearRamp_roundtrip() { + LinearRampSpeedModifier original = new LinearRampSpeedModifier(0.2F, 1.0F, 0.0F, 0.5F); + + DataResult encoded = PlaybackSpeedModifierImpl.CODEC.encodeStart(JsonOps.INSTANCE, original); + assertTrue(encoded.result().isPresent()); + JsonObject json = encoded.result().get().getAsJsonObject(); + + DataResult decoded = PlaybackSpeedModifierImpl.CODEC.parse(JsonOps.INSTANCE, json); + assertTrue(decoded.result().isPresent()); + assertEquals(original, decoded.result().get()); + } + + @Test + void linearRamp_interpolatesCorrectly() { + LinearRampSpeedModifier mod = new LinearRampSpeedModifier(0.0F, 1.0F, 0.0F, 1.0F); + // At elapsedTime=0.0, factor should be 0.0 → speed stays * 0 = 0 + assertEquals(0.0F, mod.modify(null, null, 10F, 0F, 0.0F), 1e-5F); + // At elapsedTime=0.5, factor should be 0.5 → speed * 0.5 + assertEquals(5.0F, mod.modify(null, null, 10F, 0F, 0.5F), 1e-4F); + // At elapsedTime=1.0, factor should be 1.0 → speed * 1.0 + assertEquals(10.0F, mod.modify(null, null, 10F, 0F, 1.0F), 1e-5F); + // Beyond end_time → clamp to `to`. + assertEquals(10.0F, mod.modify(null, null, 10F, 0F, 2.0F), 1e-5F); + } + + @Test + void dispatchCodec_unknownType_returnsError() { + JsonObject json = new JsonObject(); + json.add("type", new JsonPrimitive("foo:unknown")); + + DataResult decoded = PlaybackSpeedModifierImpl.CODEC.parse(JsonOps.INSTANCE, json); + assertFalse(decoded.result().isPresent()); + } +} diff --git a/src/test/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierRegistryTest.java b/src/test/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierRegistryTest.java new file mode 100644 index 0000000..a0640e3 --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierRegistryTest.java @@ -0,0 +1,91 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; + +import net.minecraft.resources.ResourceLocation; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.tiedup.remake.rig.anim.modifier.impl.LoopSectionTimeModifier; + +class PlaybackTimeModifierRegistryTest { + + @BeforeAll + static void forceClinit() { + assertNotNull(LoopSectionTimeModifier.ID); + assertNotNull(PlaybackTimeModifierImpl.CODEC); + } + + @Test + void baseImpl_registered() { + assertNotNull(PlaybackTimeModifierRegistry.getCodec(LoopSectionTimeModifier.ID)); + } + + @Test + void getCodec_unknownType_returnsNull() { + assertNull(PlaybackTimeModifierRegistry.getCodec(new ResourceLocation("foo", "bar"))); + } + + @Test + void register_duplicateId_throws() { + assertThrows(IllegalStateException.class, () -> + PlaybackTimeModifierRegistry.register(LoopSectionTimeModifier.ID, LoopSectionTimeModifier.CODEC) + ); + } + + @Test + void loopSection_roundtrip() { + LoopSectionTimeModifier original = new LoopSectionTimeModifier(0.3F, 0.8F); + + DataResult encoded = PlaybackTimeModifierImpl.CODEC.encodeStart(JsonOps.INSTANCE, original); + assertTrue(encoded.result().isPresent(), "encode error : " + encoded.error()); + JsonObject json = encoded.result().get().getAsJsonObject(); + assertEquals("tiedup:loop_section", json.get("type").getAsString()); + + DataResult decoded = PlaybackTimeModifierImpl.CODEC.parse(JsonOps.INSTANCE, json); + assertTrue(decoded.result().isPresent()); + assertEquals(original, decoded.result().get()); + } + + @Test + void loopSection_passthroughWithinWindow() { + LoopSectionTimeModifier mod = new LoopSectionTimeModifier(0.3F, 0.8F); + Pair result = mod.modify(null, null, 1F, 0.4F, 0.5F); + assertEquals(0.4F, result.getFirst(), 1e-5F); + assertEquals(0.5F, result.getSecond(), 1e-5F); + } + + @Test + void loopSection_wrapsOnOvershoot() { + LoopSectionTimeModifier mod = new LoopSectionTimeModifier(0.3F, 0.8F); + // elapsed=0.85 → overshoot 0.05 → wrapped=0.35 + Pair result = mod.modify(null, null, 1F, 0.82F, 0.85F); + assertEquals(0.35F, result.getSecond(), 1e-4F); + } + + @Test + void dispatchCodec_unknownType_returnsError() { + JsonObject json = new JsonObject(); + json.add("type", new JsonPrimitive("foo:unknown")); + + DataResult decoded = PlaybackTimeModifierImpl.CODEC.parse(JsonOps.INSTANCE, json); + assertFalse(decoded.result().isPresent()); + } +} diff --git a/src/test/java/com/tiedup/remake/rig/anim/modifier/PoseModifierRegistryTest.java b/src/test/java/com/tiedup/remake/rig/anim/modifier/PoseModifierRegistryTest.java new file mode 100644 index 0000000..32998d3 --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/anim/modifier/PoseModifierRegistryTest.java @@ -0,0 +1,184 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.modifier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.joml.Quaternionf; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; + +import net.minecraft.resources.ResourceLocation; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.tiedup.remake.rig.anim.Pose; +import com.tiedup.remake.rig.anim.modifier.impl.ChainedPoseModifier; +import com.tiedup.remake.rig.anim.modifier.impl.JointRotationOffsetModifier; +import com.tiedup.remake.rig.anim.modifier.impl.JointTranslationOffsetModifier; +import com.tiedup.remake.rig.armature.JointTransform; + +class PoseModifierRegistryTest { + + @BeforeAll + static void forceClinit() { + assertNotNull(JointRotationOffsetModifier.ID); + assertNotNull(JointTranslationOffsetModifier.ID); + assertNotNull(ChainedPoseModifier.ID); + assertNotNull(PoseModifierImpl.CODEC); + } + + // ===== Registry ===== + + @Test + void baseImpls_registered() { + assertNotNull(PoseModifierRegistry.getCodec(JointRotationOffsetModifier.ID)); + assertNotNull(PoseModifierRegistry.getCodec(JointTranslationOffsetModifier.ID)); + assertNotNull(PoseModifierRegistry.getCodec(ChainedPoseModifier.ID)); + } + + @Test + void types_returnsThreeEntries_atMinimum() { + assertTrue(PoseModifierRegistry.types().size() >= 3); + } + + @Test + void getCodec_unknownType_returnsNull() { + assertNull(PoseModifierRegistry.getCodec(new ResourceLocation("foo", "bar"))); + } + + @Test + void register_duplicateId_throws() { + assertThrows(IllegalStateException.class, () -> + PoseModifierRegistry.register(JointRotationOffsetModifier.ID, JointRotationOffsetModifier.CODEC) + ); + } + + // ===== Dispatch roundtrip ===== + + @Test + void dispatchCodec_jointRotationOffset_roundtrip() { + JointRotationOffsetModifier original = new JointRotationOffsetModifier( + "upper_arm_left", 15.0F, 5.0F, 0.0F + ); + + DataResult encoded = PoseModifierImpl.CODEC.encodeStart(JsonOps.INSTANCE, original); + assertTrue(encoded.result().isPresent(), "encode must succeed : " + encoded.error()); + JsonObject json = encoded.result().get().getAsJsonObject(); + assertEquals("tiedup:joint_rotation_offset", json.get("type").getAsString()); + assertEquals("upper_arm_left", json.get("joint").getAsString()); + + DataResult decoded = PoseModifierImpl.CODEC.parse(JsonOps.INSTANCE, json); + assertTrue(decoded.result().isPresent(), "decode must succeed : " + decoded.error()); + assertEquals(original, decoded.result().get()); + } + + @Test + void dispatchCodec_jointTranslationOffset_roundtrip() { + JointTranslationOffsetModifier original = new JointTranslationOffsetModifier( + "hand_right", 0.0F, -0.05F, 0.02F + ); + + DataResult encoded = PoseModifierImpl.CODEC.encodeStart(JsonOps.INSTANCE, original); + assertTrue(encoded.result().isPresent()); + JsonObject json = encoded.result().get().getAsJsonObject(); + assertEquals("tiedup:joint_translation_offset", json.get("type").getAsString()); + + DataResult decoded = PoseModifierImpl.CODEC.parse(JsonOps.INSTANCE, json); + assertEquals(original, decoded.result().get()); + } + + @Test + void dispatchCodec_unknownType_returnsError() { + JsonObject json = new JsonObject(); + json.add("type", new JsonPrimitive("foo:unknown_mod")); + + DataResult decoded = PoseModifierImpl.CODEC.parse(JsonOps.INSTANCE, json); + assertFalse(decoded.result().isPresent()); + assertTrue(decoded.error().isPresent()); + assertTrue(decoded.error().get().message().contains("foo:unknown_mod")); + } + + // ===== Functional behaviour ===== + + @Test + void jointRotationOffset_missingJoint_isNoOp() { + Pose pose = new Pose(); + JointRotationOffsetModifier mod = new JointRotationOffsetModifier("nope", 45F, 0F, 0F); + mod.modify(null, pose, null, 0F, 0F); + // No throw ; pose stays empty. + assertTrue(pose.getJointTransformData().isEmpty()); + } + + @Test + void jointRotationOffset_rotatesJoint() { + Pose pose = new Pose(); + JointTransform jt = JointTransform.empty(); + pose.putJointData("head", jt); + Quaternionf before = new Quaternionf(jt.rotation()); + + JointRotationOffsetModifier mod = new JointRotationOffsetModifier("head", 0F, 90F, 0F); + mod.modify(null, pose, null, 0F, 0F); + + // Y component of the quaternion should have changed (90° yaw). + assertFalse(pose.get("head").rotation().equals(before, 1e-4F)); + } + + @Test + void jointTranslationOffset_appliesDelta() { + Pose pose = new Pose(); + JointTransform jt = JointTransform.empty(); + pose.putJointData("hand_right", jt); + + JointTranslationOffsetModifier mod = new JointTranslationOffsetModifier("hand_right", 0.1F, 0.2F, 0.3F); + mod.modify(null, pose, null, 0F, 0F); + + assertEquals(0.1F, pose.get("hand_right").translation().x, 1e-5F); + assertEquals(0.2F, pose.get("hand_right").translation().y, 1e-5F); + assertEquals(0.3F, pose.get("hand_right").translation().z, 1e-5F); + } + + @Test + void chained_appliesAllModifiersInOrder() { + Pose pose = new Pose(); + JointTransform jt = JointTransform.empty(); + pose.putJointData("hand_right", jt); + + ChainedPoseModifier chain = new ChainedPoseModifier(java.util.List.of( + new JointTranslationOffsetModifier("hand_right", 0.1F, 0.0F, 0.0F), + new JointTranslationOffsetModifier("hand_right", 0.0F, 0.2F, 0.0F) + )); + chain.modify(null, pose, null, 0F, 0F); + + assertEquals(0.1F, pose.get("hand_right").translation().x, 1e-5F); + assertEquals(0.2F, pose.get("hand_right").translation().y, 1e-5F); + } + + @Test + void chained_roundtrip() { + ChainedPoseModifier original = new ChainedPoseModifier(java.util.List.of( + new JointRotationOffsetModifier("head", 10F, 0F, 0F), + new JointTranslationOffsetModifier("hand_right", 0F, 0.1F, 0F) + )); + + DataResult encoded = PoseModifierImpl.CODEC.encodeStart(JsonOps.INSTANCE, original); + assertTrue(encoded.result().isPresent(), "encode error : " + encoded.error()); + JsonElement json = encoded.result().get(); + + DataResult decoded = PoseModifierImpl.CODEC.parse(JsonOps.INSTANCE, json); + assertTrue(decoded.result().isPresent(), "decode error : " + decoded.error()); + assertTrue(decoded.result().get() instanceof ChainedPoseModifier); + } +} diff --git a/src/test/java/com/tiedup/remake/rig/anim/property/AnimationPropertyCodecTest.java b/src/test/java/com/tiedup/remake/rig/anim/property/AnimationPropertyCodecTest.java new file mode 100644 index 0000000..1c395e4 --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/anim/property/AnimationPropertyCodecTest.java @@ -0,0 +1,248 @@ +/* + * © 2026 TiedUp! Remake Contributors, distributed under GPLv3. + */ + +package com.tiedup.remake.rig.anim.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.tiedup.remake.rig.anim.LivingMotion; +import com.tiedup.remake.rig.anim.LivingMotions; +import com.tiedup.remake.rig.anim.modifier.PlaybackSpeedModifierImpl; +import com.tiedup.remake.rig.anim.modifier.PlaybackTimeModifierImpl; +import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl; +import com.tiedup.remake.rig.anim.modifier.impl.ConstantFactorSpeedModifier; +import com.tiedup.remake.rig.anim.modifier.impl.LinearRampSpeedModifier; +import com.tiedup.remake.rig.anim.modifier.impl.LoopSectionTimeModifier; +import com.tiedup.remake.rig.anim.property.AnimationProperty.ActionAnimationProperty; +import com.tiedup.remake.rig.anim.property.AnimationProperty.PlaybackSpeedModifier; +import com.tiedup.remake.rig.anim.property.AnimationProperty.PlaybackTimeModifier; +import com.tiedup.remake.rig.anim.property.AnimationProperty.PoseModifier; +import com.tiedup.remake.rig.anim.property.AnimationProperty.StaticAnimationProperty; +import com.tiedup.remake.rig.util.TimePairList; + +/** + * Phase 3 D1 — codec roundtrip and registration tests for the newly-serialized + * animation properties (Categories A & B). Pure Java — no MC bootstrap. + * + *

Covers : + *

    + *
  • Category A trivial codecs : {@code fixed_head_rotation}, + * {@code reset_living_motion}, {@code no_gravity_time}, {@code move_time}, + * {@code coord_update_time}, {@code coord_start_keyframe_index}, + * {@code coord_dest_keyframe_index}
  • + *
  • Category B dispatch codecs : {@code pose_modifier}, + * {@code play_speed_modifier}, {@code elapsed_time_modifier}
  • + *
+ */ +class AnimationPropertyCodecTest { + + @BeforeAll + static void forceClinit() { + // Touch each property so its static field is initialized before any + // getSerializableProperty(...) lookup runs. + assertNotNull(StaticAnimationProperty.FIXED_HEAD_ROTATION); + assertNotNull(StaticAnimationProperty.RESET_LIVING_MOTION); + assertNotNull(StaticAnimationProperty.PLAY_SPEED_MODIFIER); + assertNotNull(StaticAnimationProperty.ELAPSED_TIME_MODIFIER); + assertNotNull(StaticAnimationProperty.POSE_MODIFIER); + assertNotNull(ActionAnimationProperty.NO_GRAVITY_TIME); + assertNotNull(ActionAnimationProperty.MOVE_TIME); + assertNotNull(ActionAnimationProperty.COORD_UPDATE_TIME); + assertNotNull(ActionAnimationProperty.COORD_START_KEYFRAME_INDEX); + assertNotNull(ActionAnimationProperty.COORD_DEST_KEYFRAME_INDEX); + // Bootstrap the modifier registries. + assertNotNull(PoseModifierImpl.CODEC); + assertNotNull(PlaybackSpeedModifierImpl.CODEC); + assertNotNull(PlaybackTimeModifierImpl.CODEC); + // Also force LivingMotions clinit so the enum manager knows IDLE etc. + assertNotNull(LivingMotions.IDLE); + } + + // ===== Category A : trivial codecs ===== + + @Test + void fixedHeadRotation_registeredAndParses() { + AnimationProperty prop = AnimationProperty.getSerializableProperty("fixed_head_rotation"); + assertEquals(StaticAnimationProperty.FIXED_HEAD_ROTATION, prop); + + Boolean value = StaticAnimationProperty.FIXED_HEAD_ROTATION.parseFrom(new JsonPrimitive(true)); + assertTrue(value); + } + + @Test + void resetLivingMotion_registeredAndParses() { + AnimationProperty prop = AnimationProperty.getSerializableProperty("reset_living_motion"); + assertEquals(StaticAnimationProperty.RESET_LIVING_MOTION, prop); + + LivingMotion motion = StaticAnimationProperty.RESET_LIVING_MOTION.parseFrom(new JsonPrimitive("idle")); + assertEquals(LivingMotions.IDLE, motion); + } + + @Test + void resetLivingMotion_unknownName_throws() { + // parseFrom throws on empty orElseThrow (result is empty because of the error) + assertThrows(Exception.class, () -> + StaticAnimationProperty.RESET_LIVING_MOTION.parseFrom(new JsonPrimitive("totally_not_a_motion")) + ); + } + + @Test + void noGravityTime_registeredAndParses() { + AnimationProperty prop = AnimationProperty.getSerializableProperty("no_gravity_time"); + assertEquals(ActionAnimationProperty.NO_GRAVITY_TIME, prop); + + JsonArray arr = JsonParser.parseString("[0.1, 0.4, 0.6, 0.9]").getAsJsonArray(); + TimePairList tpl = ActionAnimationProperty.NO_GRAVITY_TIME.parseFrom(arr); + assertNotNull(tpl); + // (0.1..0.4) is a no-gravity window ; 0.3 is inside, 0.5 is outside. + assertTrue(tpl.isTimeInPairs(0.3F)); + assertFalse(tpl.isTimeInPairs(0.5F)); + assertTrue(tpl.isTimeInPairs(0.7F)); + } + + @Test + void noGravityTime_oddLength_codecError() { + JsonArray arr = JsonParser.parseString("[0.1, 0.4, 0.6]").getAsJsonArray(); + // parseFrom calls orElseThrow on an error → any exception is fine. + assertThrows(Exception.class, () -> + ActionAnimationProperty.NO_GRAVITY_TIME.parseFrom(arr) + ); + } + + @Test + void moveTime_registered() { + AnimationProperty prop = AnimationProperty.getSerializableProperty("move_time"); + assertEquals(ActionAnimationProperty.MOVE_TIME, prop); + } + + @Test + void coordUpdateTime_registered() { + AnimationProperty prop = AnimationProperty.getSerializableProperty("coord_update_time"); + assertEquals(ActionAnimationProperty.COORD_UPDATE_TIME, prop); + } + + @Test + void coordStartKeyframeIndex_registeredAndParses() { + AnimationProperty prop = AnimationProperty.getSerializableProperty("coord_start_keyframe_index"); + assertEquals(ActionAnimationProperty.COORD_START_KEYFRAME_INDEX, prop); + + Integer value = ActionAnimationProperty.COORD_START_KEYFRAME_INDEX.parseFrom(new JsonPrimitive(3)); + assertEquals(3, value); + } + + @Test + void coordDestKeyframeIndex_registeredAndParses() { + AnimationProperty prop = AnimationProperty.getSerializableProperty("coord_dest_keyframe_index"); + assertEquals(ActionAnimationProperty.COORD_DEST_KEYFRAME_INDEX, prop); + + Integer value = ActionAnimationProperty.COORD_DEST_KEYFRAME_INDEX.parseFrom(new JsonPrimitive(7)); + assertEquals(7, value); + } + + // ===== Category B : dispatch codecs ===== + + @Test + void poseModifier_registeredAndParses() { + AnimationProperty prop = AnimationProperty.getSerializableProperty("pose_modifier"); + assertEquals(StaticAnimationProperty.POSE_MODIFIER, prop); + + String jsonText = """ + { "type": "tiedup:joint_rotation_offset", "joint": "upper_arm_left", "pitch": 15.0 } + """; + JsonElement json = JsonParser.parseString(jsonText); + PoseModifier mod = StaticAnimationProperty.POSE_MODIFIER.parseFrom(json); + assertNotNull(mod); + assertTrue(mod instanceof PoseModifierImpl); + } + + @Test + void poseModifier_unknownType_throws() { + String jsonText = """ + { "type": "foo:unknown_mod", "joint": "head" } + """; + JsonElement json = JsonParser.parseString(jsonText); + assertThrows(Exception.class, () -> + StaticAnimationProperty.POSE_MODIFIER.parseFrom(json) + ); + } + + @Test + void playSpeedModifier_registeredAndParses() { + AnimationProperty prop = AnimationProperty.getSerializableProperty("play_speed_modifier"); + assertEquals(StaticAnimationProperty.PLAY_SPEED_MODIFIER, prop); + + String jsonText = """ + { "type": "tiedup:constant_factor", "factor": 0.5 } + """; + JsonElement json = JsonParser.parseString(jsonText); + PlaybackSpeedModifier mod = StaticAnimationProperty.PLAY_SPEED_MODIFIER.parseFrom(json); + assertNotNull(mod); + // Functional check : given speed=2.0, modifier should return 1.0. + assertEquals(1.0F, mod.modify(null, null, 2.0F, 0F, 0F), 1e-5F); + } + + @Test + void elapsedTimeModifier_registeredAndParses() { + AnimationProperty prop = AnimationProperty.getSerializableProperty("elapsed_time_modifier"); + assertEquals(StaticAnimationProperty.ELAPSED_TIME_MODIFIER, prop); + + String jsonText = """ + { "type": "tiedup:loop_section", "loop_start": 0.3, "loop_end": 0.8 } + """; + JsonElement json = JsonParser.parseString(jsonText); + PlaybackTimeModifier mod = StaticAnimationProperty.ELAPSED_TIME_MODIFIER.parseFrom(json); + assertNotNull(mod); + // Functional check : elapsed=0.9 should wrap back into [0.3..0.8). + Pair wrapped = mod.modify(null, null, 1.0F, 0.85F, 0.9F); + assertTrue(wrapped.getSecond() >= 0.3F && wrapped.getSecond() < 0.8F, + "wrapped elapsed should be in [0.3..0.8), got " + wrapped.getSecond()); + } + + // ===== Registry summary ===== + + /** + * Integration-ish : after clinit of all the touched properties, the + * dispatch map must contain all 10 new names + the 3 existing D2 names + * + the 3 existing Wave A client-properties names. + */ + @Test + void allNewPropertyNames_registered() { + String[] names = { + "fixed_head_rotation", + "reset_living_motion", + "no_gravity_time", + "move_time", + "coord_update_time", + "coord_start_keyframe_index", + "coord_dest_keyframe_index", + "pose_modifier", + "play_speed_modifier", + "elapsed_time_modifier" + }; + for (String name : names) { + assertNotNull( + AnimationProperty.getSerializableProperty(name), + "Property '" + name + "' must be registered" + ); + } + } +}