Phase 2.6 : TiedUpRenderEngine dispatch (RenderLivingEvent hook)
Câble le dispatch renderer RIG via deux subscribers auto-registered (split MOD bus pour AddLayers, FORGE bus pour RenderLivingEvent.Pre) — pas besoin de wiring explicite dans TiedUpMod. onAddLayers (MOD bus) : construit la map entityRendererProvider avec EntityType.PLAYER → TiedUpPlayerRenderer (Phase 2) ; poste PatchedRenderersEvent.Add pour extensions tierces. onRenderLiving (FORGE bus, priority HIGH) : filtre strict instanceof Player || AbstractTiedUpNpc (protège MCA villagers cf. V3-REW-10) ; vérifie patch.overrideRender() ; dispatche vers PatchedEntityRenderer et cancel l'event. Try/catch robuste : log WARN une seule fois par UUID sur exception, fallback vanilla (event non-canceled). 3 tests unitaires (pure-logic, sans MC runtime) : null-safety du filtre et idempotence du reset. Le dispatch complet sera validé Phase 2.8 runClient smoke test. Le biped armature étant identity (Phase 2.4 stub), le hook rendra le player effondré à l'origine dès qu'il s'active — attendu, warn déjà en place depuis Phase 2.5.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* Derived from Epic Fight (https://github.com/Epic-Fight/epicfight)
|
||||
* by the Epic Fight Team, licensed under GPLv3.
|
||||
* Modifications © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.render;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.google.common.collect.Maps;
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
|
||||
import net.minecraft.client.model.EntityModel;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.entity.EntityRenderer;
|
||||
import net.minecraft.client.renderer.entity.EntityRendererProvider;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.EntityRenderersEvent;
|
||||
import net.minecraftforge.client.event.RenderLivingEvent;
|
||||
import net.minecraftforge.eventbus.api.EventPriority;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.ModLoader;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||
import com.tiedup.remake.rig.TiedUpRigConstants;
|
||||
import com.tiedup.remake.rig.event.PatchedRenderersEvent;
|
||||
import com.tiedup.remake.rig.patch.LivingEntityPatch;
|
||||
import com.tiedup.remake.rig.patch.TiedUpCapabilities;
|
||||
|
||||
/**
|
||||
* Dispatch renderer RIG — Phase 2.6.
|
||||
*
|
||||
* <p>Singleton stateless qui câble deux événements Forge client-only sur deux
|
||||
* buses différents :</p>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>MOD bus — {@link EntityRenderersEvent.AddLayers}</b> : construit la
|
||||
* map {@code EntityType → PatchedEntityRenderer} (Phase 2 : PLAYER seul)
|
||||
* et poste {@link PatchedRenderersEvent.Add} sur le mod event bus pour
|
||||
* que des tiers (datapacks, modules TiedUp futurs) puissent enregistrer
|
||||
* leurs propres patched renderers.</li>
|
||||
* <li><b>FORGE bus — {@link RenderLivingEvent.Pre}</b> (priority HIGH) :
|
||||
* intercepte le render vanilla des entités whitelist, vérifie la
|
||||
* présence d'un patch avec {@code overrideRender() == true}, dispatche
|
||||
* vers le {@link PatchedEntityRenderer} approprié, puis annule le render
|
||||
* vanilla.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Les deux subscribers sont des inner classes (un par bus) — Forge n'autorise
|
||||
* pas un même class à être abonnée aux deux buses via annotation.</p>
|
||||
*
|
||||
* <p><b>Filtre strict d'entités</b> : seuls {@link Player} et
|
||||
* {@link AbstractTiedUpNpc} passent le filtre instanceof. Objectif concret :
|
||||
* <em>ne pas</em> intercepter les villagers MCA — cf.
|
||||
* {@code docs/plans/rig/V3_REWORK_BACKLOG.md} V3-REW-10. Ajouter un EntityType
|
||||
* dans {@link #entityRendererProvider} ne suffit pas à déclencher le dispatch,
|
||||
* l'entity instance doit aussi matcher le filtre ; ça protège si un tiers
|
||||
* registre une clé PLAYER pour un VillagerEntity custom (très improbable mais
|
||||
* défensif).</p>
|
||||
*
|
||||
* <p><b>Priority HIGH</b> : firer avant les subscribers à priorité NORMAL (ex :
|
||||
* mods cosmetics qui veulent canceler notre render). Si un subscriber HIGH+
|
||||
* nous a déjà canceled, on respecte et on skip.</p>
|
||||
*
|
||||
* <p><b>Robustesse</b> : toute exception pendant le dispatch est logguée une
|
||||
* seule fois par UUID d'entity (évite le spam log si le rendu crash pour une
|
||||
* entity donnée à chaque frame). L'event n'est pas canceled en cas d'exception
|
||||
* → MC fallback sur le rendu vanilla.</p>
|
||||
*
|
||||
* <p><b>Scope vs EF</b> : fork simplifié de {@code RenderEngine} EF (850 LOC).
|
||||
* Supprimé : GUI battlemode, EntityUI indicators, item renderers (stubbed),
|
||||
* FirstPersonRenderer, BossHealthOverlay. Gardé : le dispatch core
|
||||
* RenderLivingEvent + le pattern addLayers.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@SuppressWarnings("rawtypes")
|
||||
public final class TiedUpRenderEngine {
|
||||
|
||||
/**
|
||||
* Provider map — construite en {@link ModBusEvents#onAddLayers} (MOD bus)
|
||||
* puis consultée par {@link ForgeBusEvents#onRenderLiving} (FORGE bus).
|
||||
* Vide tant qu'{@code AddLayers} n'a pas firé → {@code onRenderLiving}
|
||||
* devient no-op.
|
||||
*
|
||||
* <p>Function prend {@link EntityType} en entrée (la context-free factory,
|
||||
* pattern EF) — la {@link EntityRendererProvider.Context} est déjà capturée
|
||||
* en closure dans le lambda builder.</p>
|
||||
*/
|
||||
private static final Map<EntityType<?>, Function<EntityType<?>, PatchedEntityRenderer>> entityRendererProvider =
|
||||
Maps.newHashMap();
|
||||
|
||||
/**
|
||||
* Cache résolu des renderers (construit lazy au 1er hit, via {@link #getRendererFor}).
|
||||
* Invalidé à chaque {@code AddLayers} via {@link ModBusEvents#onAddLayers}.
|
||||
*/
|
||||
private static final Map<EntityType<?>, PatchedEntityRenderer> rendererCache = Maps.newHashMap();
|
||||
|
||||
/** UUIDs pour lesquels on a déjà loggé une erreur render (anti-spam). */
|
||||
private static final Set<UUID> loggedRenderErrors = new HashSet<>();
|
||||
|
||||
private TiedUpRenderEngine() {}
|
||||
|
||||
/**
|
||||
* Résout le renderer pour un {@link EntityType} — lazy-loaded au premier
|
||||
* hit, cached ensuite. Null si aucun renderer enregistré (pass-through vers
|
||||
* vanilla).
|
||||
*/
|
||||
private static PatchedEntityRenderer getRendererFor(EntityType<?> entityType) {
|
||||
return rendererCache.computeIfAbsent(entityType, key -> {
|
||||
Function<EntityType<?>, PatchedEntityRenderer> provider = entityRendererProvider.get(key);
|
||||
return provider != null ? provider.apply(key) : null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un {@link Entity} peut être considéré pour un dispatch RIG
|
||||
* (avant même le lookup patch / renderer). Utilisé par tests unitaires
|
||||
* pour verrouiller le filtre.
|
||||
*/
|
||||
public static boolean isEligibleForDispatch(Entity entity) {
|
||||
return entity instanceof Player || entity instanceof AbstractTiedUpNpc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposé pour tests unitaires — vérifie la présence d'un provider
|
||||
* enregistré. Ne déclenche PAS la construction du renderer (qui requiert
|
||||
* {@link EntityRendererProvider.Context}).
|
||||
*/
|
||||
public static boolean hasProviderFor(EntityType<?> entityType) {
|
||||
return entityRendererProvider.containsKey(entityType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposé pour tests unitaires — reset de l'état pour éviter les fuites
|
||||
* entre tests. À ne PAS appeler depuis le code runtime.
|
||||
*/
|
||||
public static void resetForTest() {
|
||||
entityRendererProvider.clear();
|
||||
rendererCache.clear();
|
||||
loggedRenderErrors.clear();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Subscribers — split entre MOD bus (AddLayers) et FORGE bus (RenderLiving)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subscriber MOD bus — {@link EntityRenderersEvent.AddLayers} est posté
|
||||
* sur ce bus (événement lié au loading des renderers). Auto-registered via
|
||||
* l'annotation ; pas de wiring explicite requis dans {@code TiedUpMod}.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
|
||||
public static final class ModBusEvents {
|
||||
|
||||
private ModBusEvents() {}
|
||||
|
||||
/**
|
||||
* Phase 2 : on enregistre uniquement {@link EntityType#PLAYER} →
|
||||
* {@link TiedUpPlayerRenderer}. Phase 5 ajoutera les NPCs TiedUp (Damsel,
|
||||
* Kidnapper, Maid, Master).
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onAddLayers(EntityRenderersEvent.AddLayers event) {
|
||||
final EntityRendererProvider.Context context = event.getContext();
|
||||
|
||||
entityRendererProvider.clear();
|
||||
rendererCache.clear();
|
||||
|
||||
// Phase 2 : player seul. Le lambda capture la context finale.
|
||||
entityRendererProvider.put(EntityType.PLAYER, entityType -> new TiedUpPlayerRenderer(context, entityType));
|
||||
|
||||
// Extension hook : tierces parties peuvent ajouter leurs patched renderers.
|
||||
ModLoader.get().postEvent(new PatchedRenderersEvent.Add(entityRendererProvider, context));
|
||||
|
||||
TiedUpRigConstants.LOGGER.info(
|
||||
"TiedUpRenderEngine: registered {} patched entity renderer(s)",
|
||||
entityRendererProvider.size()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscriber FORGE bus — {@link RenderLivingEvent.Pre} est posté sur ce
|
||||
* bus (événement de rendu par frame). Auto-registered via l'annotation.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, bus = Mod.EventBusSubscriber.Bus.FORGE, value = Dist.CLIENT)
|
||||
public static final class ForgeBusEvents {
|
||||
|
||||
private ForgeBusEvents() {}
|
||||
|
||||
/**
|
||||
* Dispatch principal — priority HIGH pour firer avant les mods cosmetics
|
||||
* (NORMAL). Si un subscriber HIGH+ a déjà canceled, on respecte.
|
||||
*
|
||||
* <p>Generic wildcard avec {@code extends LivingEntity} — Forge fire un
|
||||
* seul event instance générique, on ne peut pas filtrer par type param.
|
||||
* Le filtre se fait par {@code instanceof} sur l'entity, et par
|
||||
* {@link #getRendererFor}.</p>
|
||||
*/
|
||||
@SubscribeEvent(priority = EventPriority.HIGH)
|
||||
public static void onRenderLiving(RenderLivingEvent.Pre<? extends LivingEntity, ? extends EntityModel<? extends LivingEntity>> event) {
|
||||
if (event.isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
LivingEntity entity = event.getEntity();
|
||||
|
||||
// STRICT FILTER — Player (Phase 2) + NPCs TiedUp (Phase 5). Protège
|
||||
// notamment contre une interception accidentelle des MCA villagers
|
||||
// (cf. V3-REW-10 dans V3_REWORK_BACKLOG.md). Bonus : si un tiers mod
|
||||
// registre un patched renderer pour un EntityType autre que Player /
|
||||
// AbstractTiedUpNpc, le filtre l'arrête (force-opt-in volontaire).
|
||||
if (!isEligibleForDispatch(entity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
PatchedEntityRenderer patchedRenderer = getRendererFor(entity.getType());
|
||||
if (patchedRenderer == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
LivingEntityPatch<?> patch = TiedUpCapabilities.getEntityPatch(entity, LivingEntityPatch.class);
|
||||
if (patch == null || !patch.overrideRender()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatchRender(event, entity, patch, patchedRenderer);
|
||||
event.setCanceled(true);
|
||||
} catch (Throwable t) {
|
||||
// Fallback vanilla — on ne cancel pas pour que MC render normalement.
|
||||
// Log une seule fois par UUID (anti-spam : erreur potentiellement à
|
||||
// chaque frame sinon).
|
||||
if (loggedRenderErrors.add(entity.getUUID())) {
|
||||
TiedUpRigConstants.LOGGER.warn(
|
||||
"TiedUpRenderEngine: patched render failed for entity {} (type={}), falling back to vanilla render",
|
||||
entity.getUUID(), entity.getType(), t
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch typé — cast raw obligatoire car {@link PatchedEntityRenderer#render}
|
||||
* est fortement typé avec des variances qu'on ne peut pas reconstruire
|
||||
* depuis l'event Forge générique.
|
||||
*
|
||||
* <p>Les casts sont sûrs par construction : l'invariant de
|
||||
* {@link #entityRendererProvider} est que le renderer construit est
|
||||
* compatible avec le {@code EntityType} clé — donc pour
|
||||
* {@code EntityType.PLAYER} → {@link TiedUpPlayerRenderer} qui accepte
|
||||
* {@code AbstractClientPlayer} → {@link LivingEntity} passed. Un
|
||||
* mismatch (improbable : seul registeur Phase 2 = player) crasherait
|
||||
* au premier accès field dans le render body ; désiré fail-fast, le
|
||||
* try/catch supérieur récupère et fallback vanilla.</p>
|
||||
*/
|
||||
@SuppressWarnings({ "unchecked" })
|
||||
private static void dispatchRender(
|
||||
RenderLivingEvent.Pre<? extends LivingEntity, ? extends EntityModel<? extends LivingEntity>> event,
|
||||
LivingEntity entity,
|
||||
LivingEntityPatch<?> patch,
|
||||
PatchedEntityRenderer patchedRenderer
|
||||
) {
|
||||
EntityRenderer<?> vanillaRenderer = event.getRenderer();
|
||||
MultiBufferSource buffer = event.getMultiBufferSource();
|
||||
PoseStack poseStack = event.getPoseStack();
|
||||
int packedLight = event.getPackedLight();
|
||||
float partialTick = event.getPartialTick();
|
||||
|
||||
patchedRenderer.render(entity, patch, vanillaRenderer, buffer, poseStack, packedLight, partialTick);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.render;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Tests du dispatch engine RIG — scope limité au code pur qui ne dépend pas
|
||||
* d'une instance {@code EntityType} / {@code Entity} Minecraft (tests pure-logic,
|
||||
* voir {@code build.gradle}).
|
||||
*
|
||||
* <p>Les cas couverts ici vérouillent les protections défensives (null-safety
|
||||
* du filtre d'entité, propreté du reset de registry entre appels {@code AddLayers}).
|
||||
* Le dispatch complet (instancier un renderer, traiter un event {@code RenderLivingEvent.Pre})
|
||||
* ne peut pas être testé sans MC runtime — ce sera validé Phase 2.7+ via
|
||||
* runClient smoke test.</p>
|
||||
*/
|
||||
class TiedUpRenderEngineTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TiedUpRenderEngine.resetForTest();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TiedUpRenderEngine.resetForTest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isEligibleForDispatchRejectsNull() {
|
||||
assertFalse(TiedUpRenderEngine.isEligibleForDispatch(null),
|
||||
"null entity must NOT be eligible — NPE guard upstream of instanceof chain");
|
||||
}
|
||||
|
||||
@Test
|
||||
void providerMapStartsEmpty() {
|
||||
// hasProviderFor doit dériver l'état courant — sans AddLayers fired, la
|
||||
// map est vide donc tous les EntityType (non testables ici sans MC
|
||||
// runtime) retournent false. On valide au minimum le contrat null-safe.
|
||||
assertFalse(TiedUpRenderEngine.hasProviderFor(null),
|
||||
"null entity type must not crash, must return false");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetForTestClearsState() {
|
||||
TiedUpRenderEngine.resetForTest();
|
||||
// Après reset, la map est vide — les helpers d'inspection retournent
|
||||
// false. Une invocation supplémentaire de reset doit rester idempotente
|
||||
// (pas de NPE sur structures pré-reset).
|
||||
TiedUpRenderEngine.resetForTest();
|
||||
assertFalse(TiedUpRenderEngine.hasProviderFor(null),
|
||||
"state reset must be idempotent and leave the engine in a clean slate");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user