Full data-driven : migrate CONTEXT_STAND_IDLE to datapack path

Remove the last Java-hardcoded StaticAnimation registration. context_stand_idle.json
now has the EF-native 'constructor' block so AnimationManager picks it up at
datapack reload, same as the 5 Phase 3 placeholders.

Consumers (PlayerPatch, LivingEntityPatch, RigAnimationTickHandler, TiedUpMod,
tests) refactored to lookup via resolveWithFallback(CONTEXT_STAND_IDLE_ID)
instead of direct static field access. resolveWithFallback handles the pre-
reload window by returning EMPTY_ANIMATION — no more null-checks in consumers.

TiedUpAnimationRegistry keeps only :
- CONTEXT_STAND_IDLE_ID constant (canonical ID)
- resolveWithFallback() helper
- WARNED_MISSING_ANIMS dedup + reset hook

Design goal : zero Java code required for any modder to add bondage anims.
Drop a JSON in assets/<modid>/animmodels/animations/, reference it from an
item JSON binding, /reload, see it fire.
This commit is contained in:
notevil
2026-04-24 13:27:45 +02:00
parent a0b6ac5b04
commit ed0fb49792
7 changed files with 199 additions and 255 deletions

View File

@@ -153,10 +153,11 @@ public class TiedUpMod {
// RIG Phase 2 — dispatcher EntityType → EntityPatch (PLAYER Phase 2, NPCs Phase 5) // RIG Phase 2 — dispatcher EntityType → EntityPatch (PLAYER Phase 2, NPCs Phase 5)
event.enqueueWork(com.tiedup.remake.rig.patch.EntityPatchProvider::registerEntityPatches); event.enqueueWork(com.tiedup.remake.rig.patch.EntityPatchProvider::registerEntityPatches);
// RIG Phase 2.7 — registre des StaticAnimation (CONTEXT_STAND_IDLE). // RIG — zero Java-side init pour les StaticAnimation. Toutes les anims
// Placeholder JSON procédural jusqu'à ce que les assets Blender arrivent // (y compris CONTEXT_STAND_IDLE) sont auto-registered via le bloc
// (cf. docs/plans/rig/ASSETS_NEEDED.md). // "constructor" de leur JSON respectif, parsé par
event.enqueueWork(com.tiedup.remake.rig.TiedUpAnimationRegistry::initStaticAnimations); // AnimationManager.readResourcepackAnimation au datapack/resource-pack
// reload. Voir TiedUpAnimationRegistry Javadoc.
} }
/** /**

View File

@@ -8,157 +8,61 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import com.tiedup.remake.rig.anim.AnimationManager; import com.tiedup.remake.rig.anim.AnimationManager;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor; import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.client.Layer;
import com.tiedup.remake.rig.anim.client.property.ClientAnimationProperties;
import com.tiedup.remake.rig.anim.types.DirectStaticAnimation;
import com.tiedup.remake.rig.anim.types.StaticAnimation; import com.tiedup.remake.rig.anim.types.StaticAnimation;
/** /**
* Phase 2.7 — registry central des {@link StaticAnimation} TiedUp. Expose les * Registry helper (lookup + fallback) pour les {@link StaticAnimation} TiedUp.
* accessors statiques (ex. {@link #CONTEXT_STAND_IDLE}) utilisés par les *
* patches + tick handler pour jouer les animations idle / walk / etc. * <h2>Data-driven — zéro Java hardcoding</h2>
* <p>Depuis la migration {@code CONTEXT_STAND_IDLE} full data-driven, ce
* registry n'instancie plus aucune {@code StaticAnimation} côté Java. Toutes
* les anims TiedUp (y compris l'idle par défaut) sont enregistrées via le bloc
* {@code "constructor"} de leur JSON respectif dans
* {@code assets/tiedup/animmodels/animations/*.json}, parsé par
* {@link AnimationManager#readResourcepackAnimation} à chaque reload de resource
* pack / datapack. Voir {@code armbinder_idle.json} ou
* {@code context_stand_idle.json} pour la forme attendue.</p>
*
* <p>Goal : un modder peut ajouter une anim TiedUp compatible sans écrire UNE
* ligne de Java — il drop un JSON dans
* {@code assets/<modid>/animmodels/animations/}, le référence depuis un item
* JSON binding, {@code /reload}, l'anim fire.</p>
*
* <h2>Ce que le registry expose</h2>
* <ul>
* <li>{@link #CONTEXT_STAND_IDLE_ID} — ID canonique de l'anim idle par
* défaut (résolue en
* {@code assets/tiedup/animmodels/animations/context_stand_idle.json}).
* Consommée par {@code PlayerPatch.initAnimator},
* {@code RigAnimationTickHandler.maybePlayIdle}, etc.</li>
* <li>{@link #resolveWithFallback(ResourceLocation)} — lookup
* {@link AnimationManager#byKey} avec fallback safe sur
* {@link TiedUpRigRegistry#EMPTY_ANIMATION} si l'ID est inconnu (datapack
* pas encore rechargé, typo modder, etc.). Jamais null.</li>
* <li>{@link #resetWarnedMissing()} — hook tests / hot-reload pour purger le
* set dedup des WARN de miss.</li>
* </ul>
* *
* <h2>Placeholder assets</h2> * <h2>Placeholder assets</h2>
* <p>Les JSON associés sont des <b>placeholders procéduraux</b> (2 keyframes * <p>Les JSON actuels sont des <b>placeholders procéduraux</b> (2 keyframes
* identity) à remplacer par des assets Blender-authored. Voir * identity) à remplacer par des assets Blender-authored. Voir
* {@code docs/plans/rig/ASSETS_NEEDED.md} section 2 pour la spec de l'anim * {@code docs/plans/rig/ASSETS_NEEDED.md} section 2 pour la spec de l'anim
* idle définitive (swing respiration subtle 3 keyframes, 2s boucle).</p> * idle définitive (swing respiration subtle 3 keyframes, 2s boucle).</p>
*
* <h2>Lifecycle</h2>
* <ul>
* <li>{@link #initStaticAnimations()} appelé au {@code FMLCommonSetupEvent}
* (via {@code event.enqueueWork(...)}). Crée les instances
* {@link DirectStaticAnimation} — pas de chargement JSON ici, juste les
* métadonnées (registry name, armature, repeat flag).</li>
* <li>Le chargement effectif du JSON ({@code JsonAssetLoader}) est lazy :
* à la première lecture de {@link StaticAnimation#getAnimationClip()},
* donc typiquement à la première frame où l'animation est jouée.</li>
* <li>Si l'asset JSON est absent / corrompu, {@code StaticAnimation.loadAnimation}
* relance une {@code AssetLoadingException}. Le tick handler
* ({@link com.tiedup.remake.rig.tick.RigAnimationTickHandler}) attrape ces
* throwables pour éviter un crash complet.</li>
* </ul>
*
* <h2>Dist</h2>
* <p>Les animations tournent côté client (l'{@code Animator} est créé via
* {@code ClientAnimator::getAnimator} côté physical client), donc le registry
* est indépendant du side pour le bootstrap mais toute la lecture JSON passe
* par {@code Minecraft.getInstance().getResourceManager()} côté client. Les
* champs sont accessibles côté serveur (validation / logs), seul
* {@link StaticAnimation#loadAnimation()} est client-heavy (et protégé par
* la dispatch server/client du {@code AnimationManager.getAnimationResourceManager()}).</p>
*/ */
public final class TiedUpAnimationRegistry { public final class TiedUpAnimationRegistry {
private TiedUpAnimationRegistry() {} private TiedUpAnimationRegistry() {}
/** Registry name de l'anim idle par défaut (résolue en /** Registry name de l'anim idle par défaut (résolue en
* {@code assets/tiedup/animmodels/animations/context_stand_idle.json}). */ * {@code assets/tiedup/animmodels/animations/context_stand_idle.json}).
* L'anim elle-même est auto-registered au datapack reload via le bloc
* {@code "constructor"} du JSON — pas d'init Java. */
public static final ResourceLocation CONTEXT_STAND_IDLE_ID = public static final ResourceLocation CONTEXT_STAND_IDLE_ID =
ResourceLocation.fromNamespaceAndPath(TiedUpRigConstants.MODID, "context_stand_idle"); ResourceLocation.fromNamespaceAndPath(TiedUpRigConstants.MODID, "context_stand_idle");
/**
* Anim idle par défaut — joue quand aucune motion active. Placeholder 2
* keyframes identity (joueur sans mouvement visible) jusqu'à ce qu'un
* asset authored Blender arrive.
*
* <p><b>Attention init order</b> : ce field est {@code null} tant que
* {@link #initStaticAnimations()} n'a pas tourné. Les sites qui
* référencent ce field doivent être gardés par un null-check, ou être
* appelés post-setup (tick handler, patch init, etc.).</p>
*
* <p>Utilise {@link DirectStaticAnimation} (vs un hand-written
* {@code StaticAnimation}) pour hériter du pattern accessor=self +
* registryName() utilisés dans {@link TiedUpRigRegistry#EMPTY_ANIMATION}.</p>
*/
public static DirectStaticAnimation CONTEXT_STAND_IDLE;
/**
* Construit les {@link StaticAnimation} TiedUp. À appeler exactement une
* fois par game, en {@code FMLCommonSetupEvent.enqueueWork(...)}.
*
* <p>Pas de chargement JSON ici — juste l'instanciation des accessors.
* La première lecture de {@code getAnimationClip()} déclenchera le load
* via {@link com.tiedup.remake.rig.asset.JsonAssetLoader}.</p>
*
* <p>Idempotent (re-appel sans effet visible) mais pas thread-safe. Ne
* devrait jamais être appelé hors du mod bus.</p>
*/
public static void initStaticAnimations() {
if (CONTEXT_STAND_IDLE != null) {
// Déjà init (hot-reload setup, test double-init). Log debug seulement.
TiedUpRigConstants.LOGGER.debug(
"TiedUpAnimationRegistry.initStaticAnimations: déjà initialisé, skip."
);
return;
}
try {
// transitionTime=GENERAL (0.15s = 3 ticks) + isRepeat=true + registryName +
// armature=BIPED. L'ordre match le ctor DirectStaticAnimation(float, boolean, ResourceLocation, AssetAccessor).
CONTEXT_STAND_IDLE = new DirectStaticAnimation(
TiedUpRigConstants.GENERAL_ANIMATION_TRANSITION_TIME,
/* isRepeat */ true,
CONTEXT_STAND_IDLE_ID,
TiedUpArmatures.BIPED
);
// Layer BASE + priority LOWEST — idle default, écrasable par toute
// autre anim. Sans ces props la default du StaticAnimation (LOWEST /
// BASE_LAYER) s'applique déjà — on les set explicitement pour la doc.
setLowestBaseLayer(CONTEXT_STAND_IDLE);
TiedUpRigConstants.LOGGER.info(
"TiedUpAnimationRegistry: CONTEXT_STAND_IDLE registered ({})",
CONTEXT_STAND_IDLE_ID
);
} catch (Throwable t) {
// Fallback : log + laisse CONTEXT_STAND_IDLE null. Le tick handler
// verra null et skippera silencieusement. Évite de tout casser si
// un asset placeholder est malformé en dev.
TiedUpRigConstants.LOGGER.error(
"TiedUpAnimationRegistry: init échoué pour CONTEXT_STAND_IDLE — "
+ "animation idle désactivée. Voir docs/plans/rig/ASSETS_NEEDED.md §2.",
t
);
}
}
/**
* Helper — set LayerType=BASE_LAYER + Priority=LOWEST sur une anim.
* Évite de forcer les callers à importer {@link ClientAnimationProperties}
* + {@link Layer}.
*
* <p>{@code @OnlyIn(CLIENT)} indirect : les properties
* {@code LAYER_TYPE}/{@code PRIORITY} sont client-only mais leur écriture
* via {@code addProperty} ne déclenche pas de class-load de
* {@code net.minecraft.client.*}. Le tag d'{@code @OnlyIn} serait
* incorrect ici (le method serait appelé depuis commonSetup). La safety
* réelle est assurée par le fait que les properties sont juste stockées
* dans la map et lues plus tard sur client uniquement (via
* {@code getLayerType()}/{@code getPriority()} tagués
* {@code @OnlyIn(CLIENT)}).</p>
*/
private static void setLowestBaseLayer(StaticAnimation anim) {
anim.addProperty(ClientAnimationProperties.LAYER_TYPE, Layer.LayerType.BASE_LAYER);
anim.addProperty(ClientAnimationProperties.PRIORITY, Layer.Priority.LOWEST);
}
/**
* Helper — vrai ssi la static anim de référence a fini l'init (tick
* handler l'utilise en early-return quand Phase 2.7 assets sont absents
* en dev test).
*/
@OnlyIn(Dist.CLIENT)
public static boolean isReady() {
return CONTEXT_STAND_IDLE != null;
}
/** /**
* Set (thread-safe) des IDs pour lesquels un WARN de fallback a déjà été * Set (thread-safe) des IDs pour lesquels un WARN de fallback a déjà été
* émis. Évite le spam log si un consumer appelle * émis. Évite le spam log si un consumer appelle
@@ -179,12 +83,15 @@ public final class TiedUpAnimationRegistry {
* si le registry ne la connaît pas. * si le registry ne la connaît pas.
* *
* <p>Utilisé par le pipeline d'équipement * <p>Utilisé par le pipeline d'équipement
* ({@code ClientRigEquipmentHandler.rebuildBondageAnimations}, P3-05) et * ({@code ClientRigEquipmentHandler.rebuildBondageAnimations}, P3-05), le
* le packet cinematic ({@code PacketPlayRigAnim.handleOnClient}, P3-12). * packet cinematic ({@code PacketPlayRigAnim.handleOnClient}, P3-12), et
* Un miss dans le registry peut survenir dans plusieurs scénarios :</p> * désormais le path idle ({@code PlayerPatch.initAnimator} +
* {@code RigAnimationTickHandler.maybePlayIdle}). Un miss dans le registry
* peut survenir dans plusieurs scénarios :</p>
* <ul> * <ul>
* <li>Typo modder dans un JSON data-driven bondage item</li> * <li>Typo modder dans un JSON data-driven bondage item</li>
* <li>Datapack pas encore rechargé ({@code /reload} pending)</li> * <li>Datapack/resource-pack pas encore rechargé (pré-{@code apply} au
* bootstrap, entre deux {@code /reload})</li>
* <li>Animation supprimée entre deux versions du mod</li> * <li>Animation supprimée entre deux versions du mod</li>
* <li>Race entre packet réception et * <li>Race entre packet réception et
* {@code AnimationManager.apply()} en début de session</li> * {@code AnimationManager.apply()} en début de session</li>
@@ -210,7 +117,7 @@ public final class TiedUpAnimationRegistry {
* runtime penserait qu'une anim réelle joue alors qu'en fait c'est un * runtime penserait qu'une anim réelle joue alors qu'en fait c'est un
* empty différent. Le singleton canonique évite ce piège.</p> * empty différent. Le singleton canonique évite ce piège.</p>
* *
* @param id l'ID registry à résoudre (ex. {@code tiedup:idle_context_bound}) * @param id l'ID registry à résoudre (ex. {@code tiedup:context_stand_idle})
* @return l'{@link AnimationAccessor} enregistré, ou * @return l'{@link AnimationAccessor} enregistré, ou
* {@link TiedUpRigRegistry#EMPTY_ANIMATION} si l'ID est inconnu. * {@link TiedUpRigRegistry#EMPTY_ANIMATION} si l'ID est inconnu.
* Jamais null. * Jamais null.

View File

@@ -77,7 +77,10 @@ public abstract class LivingEntityPatch<T extends LivingEntity> extends EntityPa
* <p>Exemple subclass :</p> * <p>Exemple subclass :</p>
* <pre> * <pre>
* protected void initAnimator(Animator a) { * protected void initAnimator(Animator a) {
* a.addLivingAnimation(LivingMotions.IDLE, TiedUpAnimationRegistry.CONTEXT_STAND_IDLE); * a.addLivingAnimation(
* LivingMotions.IDLE,
* TiedUpAnimationRegistry.resolveWithFallback(
* TiedUpAnimationRegistry.CONTEXT_STAND_IDLE_ID));
* } * }
* </pre> * </pre>
*/ */

View File

@@ -11,7 +11,7 @@ import net.minecraft.world.entity.player.Player;
import com.tiedup.remake.rig.TiedUpAnimationRegistry; import com.tiedup.remake.rig.TiedUpAnimationRegistry;
import com.tiedup.remake.rig.TiedUpArmatures; import com.tiedup.remake.rig.TiedUpArmatures;
import com.tiedup.remake.rig.TiedUpRigRegistry; import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.Animator; import com.tiedup.remake.rig.anim.Animator;
import com.tiedup.remake.rig.anim.LivingMotion; import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.LivingMotions; import com.tiedup.remake.rig.anim.LivingMotions;
@@ -35,9 +35,10 @@ import com.tiedup.remake.v2.furniture.EntityFurniture;
* {@link LocalPlayerPatch} gère le cas first-person</li> * {@link LocalPlayerPatch} gère le cas first-person</li>
* <li>Fournir un {@link #getModelMatrix(float)} avec scale player vanilla * <li>Fournir un {@link #getModelMatrix(float)} avec scale player vanilla
* ({@value #PLAYER_SCALE}) — cf. EF {@code PlayerPatch:82,176}</li> * ({@value #PLAYER_SCALE}) — cf. EF {@code PlayerPatch:82,176}</li>
* <li>Stub {@link #initAnimator(Animator)} qui bind * <li>{@link #initAnimator(Animator)} bind {@code LivingMotions.IDLE →
* {@code LivingMotions.IDLE → EMPTY_ANIMATION} (Phase 2.7 remplacera par * tiedup:context_stand_idle} (résolu via
* {@code CONTEXT_STAND_IDLE} co-authored)</li> * {@link TiedUpAnimationRegistry#resolveWithFallback} — EMPTY si le
* datapack n'est pas encore chargé)</li>
* <li>Implémentation {@link #updateMotion(boolean)} (P3-08) qui route * <li>Implémentation {@link #updateMotion(boolean)} (P3-08) qui route
* {@code currentLivingMotion} selon l'état bondage/locomotion du joueur * {@code currentLivingMotion} selon l'état bondage/locomotion du joueur
* — délègue à {@link BondageStateHelpers} pour la détection d'état et * — délègue à {@link BondageStateHelpers} pour la détection d'état et
@@ -266,29 +267,35 @@ public abstract class PlayerPatch<T extends Player> extends LivingEntityPatch<T>
/** /**
* Hook {@link LivingEntityPatch#initAnimator(Animator)} : bind la motion * Hook {@link LivingEntityPatch#initAnimator(Animator)} : bind la motion
* IDLE sur l'animation "ne fait rien" par défaut. Phase 2.7 remplacera * IDLE sur {@code tiedup:context_stand_idle}, résolue via le registry
* par {@code TiedUpAnimationRegistry.CONTEXT_STAND_IDLE} avec quelques * data-driven ({@link TiedUpAnimationRegistry#resolveWithFallback}).
* keyframes de balancement. *
* <p><b>Pre-reload window</b> : si le patch est construit avant que
* {@code AnimationManager.apply} n'ait tourné (bootstrap client, race
* entre join et resource-pack reload, etc.), l'ID {@code context_stand_idle}
* n'est pas encore dans {@code animationByName} et
* {@code resolveWithFallback} retourne {@link TiedUpRigRegistry#EMPTY_ANIMATION}.
* C'est idempotent côté animator — une prochaine passe de
* {@link com.tiedup.remake.rig.tick.RigAnimationTickHandler#maybePlayIdle}
* re-bind sur la vraie anim une fois le datapack chargé (self-heal).</p>
* *
* <p>L'ordre EF ajoute ~25 motions différentes (WALK, RUN, SNEAK, SIT, * <p>L'ordre EF ajoute ~25 motions différentes (WALK, RUN, SNEAK, SIT,
* SLEEP, etc.). On se limite à IDLE Phase 2.4 — ajouter les autres * SLEEP, etc.). On se limite à IDLE ici — ajouter les autres sans anim
* sans anim source = pollution registre pour rien. Au fur et à mesure * source = pollution registre pour rien. Au fur et à mesure que les JSON
* que les JSON co-authored arrivent (Phase 2.7 / 4), on ajoute les * co-authored arrivent, on ajoute les binds correspondants (ou on délègue
* binds correspondants.</p> * au pipeline d'équipement data-driven pour les motions bondage-gated).</p>
*/ */
@Override @Override
protected void initAnimator(Animator animator) { protected void initAnimator(Animator animator) {
super.initAnimator(animator); super.initAnimator(animator);
// Phase 2.7 : si le registry a init, on bind sur CONTEXT_STAND_IDLE // Full data-driven : lookup par ID, EMPTY_ANIMATION fallback si
// (placeholder procédural 2-keyframes identity — voir // l'asset n'est pas encore chargé (rare mais possible au bootstrap).
// TiedUpAnimationRegistry + ASSETS_NEEDED.md §2). Sinon fallback sur // Le tick handler self-heal le bind une fois le datapack loadé.
// EMPTY_ANIMATION — peut arriver si le patch est construit avant que AnimationAccessor<? extends StaticAnimation> idle =
// FMLCommonSetupEvent n'ait tourné (rare mais pas impossible en dev). TiedUpAnimationRegistry.resolveWithFallback(
StaticAnimation idle = TiedUpAnimationRegistry.CONTEXT_STAND_IDLE; TiedUpAnimationRegistry.CONTEXT_STAND_IDLE_ID
animator.addLivingAnimation(
LivingMotions.IDLE,
idle != null ? idle.getAccessor() : TiedUpRigRegistry.EMPTY_ANIMATION
); );
animator.addLivingAnimation(LivingMotions.IDLE, idle);
} }
} }

View File

@@ -19,9 +19,12 @@ import net.minecraftforge.fml.common.Mod;
import org.slf4j.Logger; import org.slf4j.Logger;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.core.TiedUpMod; import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.rig.TiedUpAnimationRegistry; import com.tiedup.remake.rig.TiedUpAnimationRegistry;
import com.tiedup.remake.rig.TiedUpRigRegistry; import com.tiedup.remake.rig.TiedUpRigRegistry;
import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.Animator; import com.tiedup.remake.rig.anim.Animator;
import com.tiedup.remake.rig.anim.LivingMotions; import com.tiedup.remake.rig.anim.LivingMotions;
import com.tiedup.remake.rig.anim.client.ClientAnimator; import com.tiedup.remake.rig.anim.client.ClientAnimator;
@@ -41,8 +44,9 @@ import com.tiedup.remake.rig.patch.TiedUpCapabilities;
* etc.)</li> * etc.)</li>
* <li>Si aucune animation "réelle" n'est active (= on joue encore * <li>Si aucune animation "réelle" n'est active (= on joue encore
* l'{@code EMPTY_ANIMATION} du fallback initial), déclenche une * l'{@code EMPTY_ANIMATION} du fallback initial), déclenche une
* transition vers {@link TiedUpAnimationRegistry#CONTEXT_STAND_IDLE} * transition vers {@code tiedup:context_stand_idle} (résolu via
* (0.2s) — preuve vivante que le pipeline tourne Phase 2.7.</li> * {@link TiedUpAnimationRegistry#resolveWithFallback}, 0.2s) —
* preuve vivante que le pipeline tourne Phase 2.7.</li>
* </ol> * </ol>
* *
* <h2>Choix de design</h2> * <h2>Choix de design</h2>
@@ -58,11 +62,11 @@ import com.tiedup.remake.rig.patch.TiedUpCapabilities;
* tick. Un patch qui throw ne cascade pas sur les autres. L'erreur * tick. Un patch qui throw ne cascade pas sur les autres. L'erreur
* est logguée <b>une seule fois par UUID</b> pour éviter de spammer * est logguée <b>une seule fois par UUID</b> pour éviter de spammer
* la console.</li> * la console.</li>
* <li><b>Registry pas ready → noop</b> : si * <li><b>Datapack pas encore appliqué → noop</b> : si
* {@link TiedUpAnimationRegistry#CONTEXT_STAND_IDLE} est null (setup * {@link TiedUpAnimationRegistry#resolveWithFallback} retourne
* pas encore exécuté, ou fallback d'échec), on ne crash pas — on * {@link TiedUpRigRegistry#EMPTY_ANIMATION} (ID inconnu pendant la
* skippe juste le trigger idle. L'animator tourne quand même * fenêtre pré-reload), on ne crash pas — on skippe juste le trigger
* (EMPTY_ANIMATION en boucle).</li> * idle. L'animator tourne quand même (EMPTY_ANIMATION en boucle).</li>
* </ul> * </ul>
* *
* <h2>Futur Phase 3+</h2> * <h2>Futur Phase 3+</h2>
@@ -190,13 +194,16 @@ public final class RigAnimationTickHandler {
} }
/** /**
* Déclenche la transition vers {@code CONTEXT_STAND_IDLE} si : * Déclenche la transition vers {@code tiedup:context_stand_idle} si :
* <ul> * <ul>
* <li>Le registry est ready (placeholder asset loadé)</li> * <li>Le datapack a été appliqué (l'ID est résolu par
* {@link TiedUpAnimationRegistry#resolveWithFallback} — si fallback
* EMPTY, on skip le trigger).</li>
* <li>Le patch est dans {@code LivingMotions.IDLE} (pas en action * <li>Le patch est dans {@code LivingMotions.IDLE} (pas en action
* particulière — walk, sit, etc. Phase 3+)</li> * particulière — walk, sit, etc. Phase 3+)</li>
* <li>L'animation actuellement jouée sur le base layer n'est pas déjà * <li>L'animation actuellement jouée sur le base layer n'est pas déjà
* CONTEXT_STAND_IDLE (évite le re-trigger à chaque tick)</li> * {@code tiedup:context_stand_idle} (compare par {@code registryName()},
* stable à travers les reloads — évite le re-trigger à chaque tick)</li>
* </ul> * </ul>
* *
* <p>Transition 0.2s (4 ticks @ 20tps) pour un fondu doux avec * <p>Transition 0.2s (4 ticks @ 20tps) pour un fondu doux avec
@@ -204,10 +211,6 @@ public final class RigAnimationTickHandler {
* plus long on passera la valeur en param.</p> * plus long on passera la valeur en param.</p>
*/ */
private static void maybePlayIdle(LivingEntityPatch<?> patch, Animator animator) { private static void maybePlayIdle(LivingEntityPatch<?> patch, Animator animator) {
if (!TiedUpAnimationRegistry.isReady()) {
return;
}
if (patch.getCurrentLivingMotion() != LivingMotions.IDLE) { if (patch.getCurrentLivingMotion() != LivingMotions.IDLE) {
return; return;
} }
@@ -218,46 +221,51 @@ public final class RigAnimationTickHandler {
return; return;
} }
// Check si la CONTEXT_STAND_IDLE est déjà l'anim courante du base layer. // Resolve target via le registry data-driven. Si le datapack n'a pas
// getPlayerFor(null) retourne base layer player (non-null en EF). // encore été appliqué (pré-reload window), on récupère EMPTY_ANIMATION
// — on skip le trigger (rien à jouer de plus que le bind EMPTY initial).
AnimationAccessor<? extends StaticAnimation> target =
TiedUpAnimationRegistry.resolveWithFallback(
TiedUpAnimationRegistry.CONTEXT_STAND_IDLE_ID
);
if (target == TiedUpRigRegistry.EMPTY_ANIMATION) {
return;
}
ResourceLocation targetId = target.registryName();
// Check si context_stand_idle est déjà l'anim courante du base layer.
// Comparaison par registryName() (stable à travers les reloads) vs.
// l'ancien check d'identité qui breakait après un datapack reload —
// chaque reload ré-instantie la StaticAnimation, donc l'ancien pointeur
// capturé à l'init ne matchait plus l'instance post-reload.
AssetAccessor<? extends DynamicAnimation> currentAnim = AssetAccessor<? extends DynamicAnimation> currentAnim =
clientAnimator.baseLayer.animationPlayer.getAnimation(); clientAnimator.baseLayer.animationPlayer.getAnimation();
StaticAnimation target = TiedUpAnimationRegistry.CONTEXT_STAND_IDLE;
if (currentAnim != null && currentAnim.get() != null) { if (currentAnim != null && currentAnim.get() != null) {
// Compare directement les instances — les StaticAnimation sont ResourceLocation currentId = currentAnim.registryName();
// singletons (registry constructor pattern). equals() fallback if (currentId != null && currentId.equals(targetId)) {
// sur id et les accessors sont null pour EMPTY, donc ID-based
// comparison pas fiable ici. Instance check suffit.
if (currentAnim.get() == target) {
return; return;
} }
} }
// Self-heal : si le bind IDLE d'un patch (PlayerPatch.addAnimations) // Self-heal : si le bind IDLE d'un patch (PlayerPatch.initAnimator)
// a été construit AVANT que TiedUpAnimationRegistry.initStaticAnimations() // a été construit AVANT que AnimationManager.apply() n'ait chargé le
// ait tourné, le bind pointe encore sur EMPTY_ANIMATION au lieu de // JSON context_stand_idle (bootstrap race, resource-pack reload
// CONTEXT_STAND_IDLE. On rebind ici — idempotent : les addLivingAnimation // pending au join), le bind pointe encore sur EMPTY_ANIMATION. On
// suivantes écrasent simplement la précédente entry dans la map. // rebind ici — idempotent : les addLivingAnimation suivantes écrasent
// // simplement la précédente entry dans la map.
// Ce cas se produit si :
// - Le patch construit la première fois sur le login (pré-setup)
// - Un test runClient démarre avant FMLCommonSetupEvent async enqueueWork
// - L'asset CONTEXT_STAND_IDLE est en fallback EMPTY après échec JSON
// (registry throw swallowed → CONTEXT_STAND_IDLE reste null cf. le
// isReady() guard au-dessus → on n'entre même pas dans ce bloc).
// //
// On utilise getLivingAnimation(motion, defaultGetter) avec null comme // On utilise getLivingAnimation(motion, defaultGetter) avec null comme
// default pour distinguer "pas de bind" d'un bind explicite vers // default pour distinguer "pas de bind" d'un bind explicite vers
// EMPTY_ANIMATION. Dans les deux cas on rebind. // EMPTY_ANIMATION. Dans les deux cas on rebind vers le target résolu.
AssetAccessor<? extends StaticAnimation> currentIdleBind = AssetAccessor<? extends StaticAnimation> currentIdleBind =
clientAnimator.getLivingAnimation(LivingMotions.IDLE, null); clientAnimator.getLivingAnimation(LivingMotions.IDLE, null);
if (currentIdleBind == null || currentIdleBind == TiedUpRigRegistry.EMPTY_ANIMATION) { if (currentIdleBind == null || currentIdleBind == TiedUpRigRegistry.EMPTY_ANIMATION) {
clientAnimator.addLivingAnimation(LivingMotions.IDLE, target.getAccessor()); clientAnimator.addLivingAnimation(LivingMotions.IDLE, target);
} }
clientAnimator.playAnimation(target.getAccessor(), 0.2F); clientAnimator.playAnimation(target, 0.2F);
} }
/** /**

View File

@@ -1,6 +1,9 @@
{ {
"_comment": "PLACEHOLDER Phase 2.7 — animation idle 2-keyframes identity (joueur statique, pas de balancement). Boucle 2 secondes. DOIT parser avec JsonAssetLoader.loadClipForAnimation(). À remplacer par un export Blender authored (respiration/balancement subtle) quand dispo. Voir docs/plans/rig/ASSETS_NEEDED.md section 2.", "_comment": "PLACEHOLDER Phase 2.7 — animation idle 2-keyframes identity (joueur statique, pas de balancement). Boucle 2 secondes. DOIT parser avec JsonAssetLoader.loadClipForAnimation(). À remplacer par un export Blender authored (respiration/balancement subtle) quand dispo. Voir docs/plans/rig/ASSETS_NEEDED.md section 2.",
"_comment_armature": "Pas de champ 'armature' ici — le loader ne lit pas ce champ au runtime. L'armature est résolue par le call site (voir DirectStaticAnimation ctor dans TiedUpAnimationRegistry.initStaticAnimations, qui passe TiedUpArmatures.BIPED au super).", "_comment_registration": "Auto-registered via AnimationManager.readResourcepackAnimation (bloc 'constructor' ci-dessous). isRepeat=true (idle loop), transition=0.15s (GENERAL). Armature résolue via InstantiateInvoker → TiedUpArmatures.get → BIPED. Plus aucun init Java-side : full data-driven, même pattern que les 5 placeholders Phase 3 (armbinder_*, classic_collar_*).",
"constructor": {
"invocation_command": "(0.15#F,true#Z,tiedup:context_stand_idle#java.lang.String,tiedup:biped#com.tiedup.remake.rig.armature.Armature)#com.tiedup.remake.rig.anim.types.StaticAnimation"
},
"format": "ATTRIBUTES", "format": "ATTRIBUTES",
"animation": [ "animation": [
{ {

View File

@@ -10,85 +10,100 @@ import static org.junit.jupiter.api.Assertions.assertSame;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import com.tiedup.remake.rig.anim.types.DirectStaticAnimation; import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
import com.tiedup.remake.rig.anim.types.StaticAnimation;
/** /**
* Tests du registry {@link TiedUpAnimationRegistry}. * Tests du registry {@link TiedUpAnimationRegistry}.
* *
* <p>Ces tests verrouillent :</p> * <p>Depuis la migration full data-driven de {@code CONTEXT_STAND_IDLE}, il n'y
* a plus AUCUN field statique {@code StaticAnimation} hardcoded Java — toutes
* les anims sont enregistrées via le bloc {@code "constructor"} de leur JSON
* respectif, parsé par {@link com.tiedup.remake.rig.anim.AnimationManager#readResourcepackAnimation}
* au datapack/resource-pack reload. Les tests s'alignent sur ce contrat :</p>
*
* <ul> * <ul>
* <li>L'idempotence de {@link TiedUpAnimationRegistry#initStaticAnimations()} * <li>{@link TiedUpAnimationRegistry#CONTEXT_STAND_IDLE_ID} — l'ID canonique
* — un double-appel ne doit pas créer une nouvelle instance / doubler * est une constante stable, doit matcher le path du JSON
* l'enregistrement. Pattern important car {@code FMLCommonSetupEvent}
* peut firer plusieurs fois sur hot-reload dev ou test runner.</li>
* <li>Le {@code registryName()} de {@link TiedUpAnimationRegistry#CONTEXT_STAND_IDLE}
* — alignement avec le path du JSON asset
* ({@code assets/tiedup/animmodels/animations/context_stand_idle.json}). * ({@code assets/tiedup/animmodels/animations/context_stand_idle.json}).
* Si quelqu'un renomme l'asset sans toucher le code (ou vice-versa), ce * Si quelqu'un renomme l'asset sans toucher le code (ou vice-versa), ce
* test attrape immédiatement la désynchronisation.</li> * test attrape immédiatement la désynchronisation.</li>
* <li>{@link TiedUpAnimationRegistry#resolveWithFallback(ResourceLocation)}
* — pré-reload (pas d'appel à {@code AnimationManager.apply}) retourne
* {@link TiedUpRigRegistry#EMPTY_ANIMATION}. C'est le comportement fallback
* safe attendu par les consumers ({@code PlayerPatch.initAnimator},
* {@code RigAnimationTickHandler.maybePlayIdle}).</li>
* </ul> * </ul>
* *
* <p>Les tests n'ont PAS besoin du runtime MC — {@code initStaticAnimations()} * <p>Les tests n'ont PAS besoin du runtime MC — on teste les invariants purs
* construit juste les instances {@link DirectStaticAnimation} (pas de * du registry (constante + fallback) sans toucher au resource manager.</p>
* {@code loadAnimation()} déclenché tant que {@code getAnimationClip()} n'est
* pas lu). La {@code ResourceLocation} créée via le constructor n'a pas besoin
* de Minecraft runtime non plus.</p>
*/ */
class TiedUpAnimationRegistryTest { class TiedUpAnimationRegistryTest {
/** @AfterEach
* Appeler {@link TiedUpAnimationRegistry#initStaticAnimations()} deux fois void resetDedupSet() {
* de suite doit être un no-op sur le second appel — le champ // Isolation : un test qui warn sur un ID pollue le set dedup statique
* {@code CONTEXT_STAND_IDLE} garde la même référence. // partagé — on purge pour ne pas fausser les tests suivants (ordre
* // d'exécution JUnit non garanti).
* <p>Si l'init n'était pas idempotent, un double-call (scénario hot-reload TiedUpAnimationRegistry.resetWarnedMissing();
* dev, test runner partageant la JVM, etc.) recréerait l'instance et
* invaliderait tous les sites qui l'ont capturée en field final ou local.
* Le guard {@code if (CONTEXT_STAND_IDLE != null) return;} protège de ça —
* ce test s'assure qu'il n'est pas retiré en régression.</p>
*/
@Test
void initStaticAnimationsIsIdempotent() {
TiedUpAnimationRegistry.initStaticAnimations();
assertNotNull(TiedUpAnimationRegistry.CONTEXT_STAND_IDLE,
"Après initStaticAnimations, CONTEXT_STAND_IDLE doit être non-null");
DirectStaticAnimation firstInstance = TiedUpAnimationRegistry.CONTEXT_STAND_IDLE;
// Deuxième call — doit être no-op, pas recréer l'instance.
TiedUpAnimationRegistry.initStaticAnimations();
assertSame(firstInstance, TiedUpAnimationRegistry.CONTEXT_STAND_IDLE,
"Un second call à initStaticAnimations ne doit pas remplacer l'instance — "
+ "le guard `if (CONTEXT_STAND_IDLE != null) return;` serait cassé sinon");
} }
/** /**
* Le {@code registryName()} de {@code CONTEXT_STAND_IDLE} doit matcher * {@link TiedUpAnimationRegistry#CONTEXT_STAND_IDLE_ID} doit matcher
* {@code tiedup:context_stand_idle} — alignement avec le path du JSON * {@code tiedup:context_stand_idle} — alignement avec le path du JSON
* asset {@code assets/tiedup/animmodels/animations/context_stand_idle.json}. * asset {@code assets/tiedup/animmodels/animations/context_stand_idle.json}.
* *
* <p>Si le registry name dérive du path (via la résolution * <p>{@link com.tiedup.remake.rig.anim.AnimationManager#readResourcepackAnimation}
* {@code resourceLocation = animmodels/animations/${registry.path}.json}), * dérive l'ID depuis le path du fichier via {@code pathToId}. Une
* une désynchronisation code vs asset se traduit par un * désynchronisation code vs asset se traduit par un
* {@code AssetLoadingException} runtime. Ce test est un smoke check rapide * {@code resolveWithFallback} qui retourne EMPTY runtime — silent bug.
* au niveau de l'identité du registry name.</p> * Ce test est un smoke check rapide au niveau de l'identité du registry
* name.</p>
*/ */
@Test @Test
void contextStandIdleHasRegistryName() { void contextStandIdleIdMatchesAssetPath() {
TiedUpAnimationRegistry.initStaticAnimations(); ResourceLocation expected = ResourceLocation.fromNamespaceAndPath(
TiedUpRigConstants.MODID, "context_stand_idle"
);
DirectStaticAnimation anim = TiedUpAnimationRegistry.CONTEXT_STAND_IDLE; assertEquals(expected, TiedUpAnimationRegistry.CONTEXT_STAND_IDLE_ID,
assertNotNull(anim, "CONTEXT_STAND_IDLE doit être initialisé"); "CONTEXT_STAND_IDLE_ID doit être tiedup:context_stand_idle — "
+ "désynchronisation avec assets/tiedup/animmodels/animations/context_stand_idle.json sinon.");
}
ResourceLocation expected = /**
ResourceLocation.fromNamespaceAndPath(TiedUpRigConstants.MODID, "context_stand_idle"); * Pré-reload (pas d'appel à {@code AnimationManager.apply}), résoudre
* {@link TiedUpAnimationRegistry#CONTEXT_STAND_IDLE_ID} doit retourner
* {@link TiedUpRigRegistry#EMPTY_ANIMATION} (jamais null).
*
* <p>C'est le contrat fallback safe consommé par {@code PlayerPatch.initAnimator}
* (bind IDLE au construct-time de la capability, potentiellement avant le
* premier datapack apply). Le tick handler
* {@link com.tiedup.remake.rig.tick.RigAnimationTickHandler#maybePlayIdle}
* self-heal ensuite quand le datapack arrive.</p>
*
* <p>Si ce test échoue avec une instance différente d'EMPTY, c'est qu'un
* refactor a cassé le contrat singleton — plusieurs sites runtime
* ({@code Layer#off}, {@code AnimationPlayer#isEmpty}) testent l'identité
* via {@code == EMPTY_ANIMATION}, donc retourner un autre empty casserait
* ces checks silencieusement.</p>
*/
@Test
void resolveContextStandIdle_preReload_fallbacksToEmpty() {
AnimationAccessor<? extends StaticAnimation> resolved =
TiedUpAnimationRegistry.resolveWithFallback(
TiedUpAnimationRegistry.CONTEXT_STAND_IDLE_ID
);
assertEquals(expected, anim.registryName(), assertNotNull(resolved,
"CONTEXT_STAND_IDLE.registryName() doit être tiedup:context_stand_idle" "resolveWithFallback ne doit jamais retourner null"
+ "désynchronisation avec assets/tiedup/animmodels/animations/context_stand_idle.json sinon"); + "les consumers (PlayerPatch, tick handler) supposent non-null.");
assertSame(TiedUpRigRegistry.EMPTY_ANIMATION, resolved,
"Pré-reload (AnimationManager.apply pas encore appelé), "
+ "resolveWithFallback doit retourner le singleton EMPTY_ANIMATION. "
+ "Les consumers détectent cette instance pour skip le play (noop).");
} }
} }