P3-02 : add AnimationBindings record + DataDrivenItemDefinition.animations field

New record AnimationBindings (v2/bondage/datadriven/) carries the JSON
item -> rig animation bindings that P3-03 (parser) will populate and
P3-05 (ClientRigEquipmentHandler.rebuildBondageAnimations) will consume
to rebuild the player's livingAnimations map on equip/unequip.

Fields :
- livingMotions : Map<LivingMotion, ResourceLocation> (immutable, never null,
  Map.copyOf defensive copy in compact ctor, null input collapsed to emptyMap)
- onEquip / onUnequip : optional nullable one-shot triggers
- EMPTY constant + isEmpty() for the "no binding" fast path

DataDrivenItemDefinition gains a nullable "animations" field (null = vanilla
behavior, preserves backward compat for items without rig bindings).
DataDrivenItemParser call-site updated to pass null for now — wiring happens
in P3-03. Single call-site updated (parser only), no factories or tests
constructed the record directly.

7 unit tests cover construction paths, null tolerance, defensive map copy,
isEmpty semantics and the EMPTY constant. Full rig suite still 65 GREEN.
This commit is contained in:
notevil
2026-04-23 13:22:25 +02:00
parent ddaa25b971
commit 5d108f51b4
4 changed files with 226 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.v2.bondage.datadriven;
import java.util.Collections;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.Nullable;
import com.tiedup.remake.rig.anim.LivingMotion;
/**
* Bindings animation pour un item bondage data-driven.
*
* <p>Porte le mapping {@link LivingMotion} &rarr; {@link ResourceLocation} animation
* + eventuels one-shots {@code onEquip} / {@code onUnequip}. Consomme par
* {@code ClientRigEquipmentHandler.rebuildBondageAnimations} (P3-05) pour
* builder la map {@code livingAnimations} de l'animator quand un item
* bondage est equipe.
*
* <p>Parse via {@code DataDrivenItemParser} (P3-03) depuis JSON :
* <pre>{@code
* "animations": {
* "living_motions": { "IDLE": "mymod:arms_cuffed_idle", ... },
* "on_equip": "mymod:cuffs_equip_oneshot",
* "on_unequip": "mymod:cuffs_unequip_oneshot"
* }
* }</pre>
*
* @param livingMotions map immutable motion &rarr; anim ID (jamais null, peut etre vide)
* @param onEquip optionnel one-shot au moment de l'equipement (null = pas de trigger)
* @param onUnequip optionnel one-shot au moment du desequipement (null = pas de trigger)
*/
public record AnimationBindings(
Map<LivingMotion, ResourceLocation> livingMotions,
@Nullable ResourceLocation onEquip,
@Nullable ResourceLocation onUnequip
) {
/** Shortcut pour bindings totalement vides (equivalent no-op). */
public static final AnimationBindings EMPTY = new AnimationBindings(Map.of(), null, null);
/** Constructor canonique : force livingMotions immutable + non-null. */
public AnimationBindings {
livingMotions = livingMotions == null ? Collections.emptyMap() : Map.copyOf(livingMotions);
}
/** {@code true} si aucun binding n'est defini (ni living motions, ni one-shots). */
public boolean isEmpty() {
return livingMotions.isEmpty() && onEquip == null && onUnequip == null;
}
}

View File

@@ -105,6 +105,20 @@ public record DataDrivenItemDefinition(
*/
Map<String, Set<String>> animationBones,
/**
* Optional living-motion animation bindings (P3 rig system).
*
* <p>When non-null, the client rig equipment handler rebuilds the player's
* living animation map with these bindings whenever this item is equipped.
* When null, no animation bindings are applied (vanilla behavior).</p>
*
* <p>Distinct from {@link #animationBones} : {@code animationBones} is a
* per-clip bone whitelist for the PlayerAnimator pipeline (V1-era),
* whereas {@code animations} drives the Epic Fight-style rig animator
* (P3-05).</p>
*/
@Nullable AnimationBindings animations,
/** Raw component configs from JSON, keyed by ComponentType. */
Map<ComponentType, JsonObject> componentConfigs
) {

View File

@@ -347,6 +347,9 @@ public final class DataDrivenItemParser {
movementModifier,
creator,
animationBones,
// P3-02 : animations parsing will be wired in P3-03. For now pass
// null so existing items keep their vanilla animator behavior.
null,
componentConfigs
);
}

View File

@@ -0,0 +1,154 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.v2.bondage.datadriven;
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 java.util.HashMap;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import org.junit.jupiter.api.Test;
import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.LivingMotions;
/**
* Tests unitaires pour {@link AnimationBindings} :
* <ul>
* <li>construction normale et cas nullables (P3-02),</li>
* <li>comportement du compact ctor (null -> empty map),</li>
* <li>immutabilite defensive de {@code livingMotions},</li>
* <li>contrat de {@link AnimationBindings#isEmpty()},</li>
* <li>constante {@link AnimationBindings#EMPTY}.</li>
* </ul>
*
* Aucun MC runtime requis — {@link ResourceLocation} est utilisable hors
* MinecraftServer, et {@link LivingMotion} est un interface pur.
*/
class AnimationBindingsTest {
private static final ResourceLocation ANIM_IDLE =
ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_idle");
private static final ResourceLocation ANIM_WALK =
ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_walk");
private static final ResourceLocation ANIM_EQUIP =
ResourceLocation.fromNamespaceAndPath("tiedup", "cuffs_equip");
private static final ResourceLocation ANIM_UNEQUIP =
ResourceLocation.fromNamespaceAndPath("tiedup", "cuffs_unequip");
/** Construction nominale avec des donnees valides. */
@Test
void construction_withValidData_succeeds() {
Map<LivingMotion, ResourceLocation> motions = Map.of(
LivingMotions.IDLE, ANIM_IDLE,
LivingMotions.WALK, ANIM_WALK
);
AnimationBindings bindings = new AnimationBindings(motions, ANIM_EQUIP, ANIM_UNEQUIP);
assertEquals(2, bindings.livingMotions().size());
assertEquals(ANIM_IDLE, bindings.livingMotions().get(LivingMotions.IDLE));
assertEquals(ANIM_WALK, bindings.livingMotions().get(LivingMotions.WALK));
assertEquals(ANIM_EQUIP, bindings.onEquip());
assertEquals(ANIM_UNEQUIP, bindings.onUnequip());
}
/**
* Le compact ctor doit remplacer un {@code livingMotions} null par
* {@link java.util.Collections#emptyMap()} — jamais laisser le champ a null.
*/
@Test
void construction_withNullLivingMotions_usesEmptyMap() {
AnimationBindings bindings = new AnimationBindings(null, null, null);
assertNotNull(bindings.livingMotions(),
"livingMotions ne doit jamais etre null (compact ctor garantit non-null)");
assertTrue(bindings.livingMotions().isEmpty());
}
/** {@code onEquip} / {@code onUnequip} null sont legaux (optionnels). */
@Test
void construction_withNullEquip_isTolerated() {
Map<LivingMotion, ResourceLocation> motions = Map.of(LivingMotions.IDLE, ANIM_IDLE);
AnimationBindings bindings = new AnimationBindings(motions, null, null);
assertEquals(1, bindings.livingMotions().size());
assertNull(bindings.onEquip());
assertNull(bindings.onUnequip());
}
/**
* {@link AnimationBindings#isEmpty()} renvoie {@code true} quand tous les
* champs sont vides/absents.
*/
@Test
void isEmpty_returnsTrueForFullyEmpty() {
AnimationBindings bindings = new AnimationBindings(Map.of(), null, null);
assertTrue(bindings.isEmpty());
}
/**
* {@link AnimationBindings#isEmpty()} doit renvoyer {@code false} des qu'au
* moins un champ est present (motion OU one-shot equip OU one-shot unequip).
*/
@Test
void isEmpty_returnsFalseIfAnyField() {
AnimationBindings onlyMotion =
new AnimationBindings(Map.of(LivingMotions.IDLE, ANIM_IDLE), null, null);
AnimationBindings onlyEquip =
new AnimationBindings(Map.of(), ANIM_EQUIP, null);
AnimationBindings onlyUnequip =
new AnimationBindings(Map.of(), null, ANIM_UNEQUIP);
assertFalse(onlyMotion.isEmpty(), "isEmpty doit etre false si livingMotions non vide");
assertFalse(onlyEquip.isEmpty(), "isEmpty doit etre false si onEquip non null");
assertFalse(onlyUnequip.isEmpty(), "isEmpty doit etre false si onUnequip non null");
}
/**
* Le compact ctor doit faire une copie defensive du Map passe — mutation de la
* source apres construction ne doit PAS affecter le record.
*/
@Test
void immutability_livingMotionsCopyIsDefensive() {
Map<LivingMotion, ResourceLocation> mutable = new HashMap<>();
mutable.put(LivingMotions.IDLE, ANIM_IDLE);
AnimationBindings bindings = new AnimationBindings(mutable, null, null);
// Mutation de la map source apres construction
mutable.put(LivingMotions.WALK, ANIM_WALK);
mutable.remove(LivingMotions.IDLE);
// Le record garde bien l'etat initial
assertEquals(1, bindings.livingMotions().size(),
"La map du record ne doit pas refleter les mutations de la source");
assertEquals(ANIM_IDLE, bindings.livingMotions().get(LivingMotions.IDLE),
"La map du record doit toujours contenir l'entry initiale");
// Et la map retournee doit elle-meme etre immutable (Map.copyOf)
assertThrows(UnsupportedOperationException.class,
() -> bindings.livingMotions().put(LivingMotions.RUN, ANIM_WALK),
"livingMotions() doit retourner un Map immutable");
}
/** La constante {@link AnimationBindings#EMPTY} est bien vide. */
@Test
void empty_constant_isEmpty() {
assertTrue(AnimationBindings.EMPTY.isEmpty());
assertTrue(AnimationBindings.EMPTY.livingMotions().isEmpty());
assertNull(AnimationBindings.EMPTY.onEquip());
assertNull(AnimationBindings.EMPTY.onUnequip());
}
}