Morph Compatibility

AzureLib makes entities animate when a player is morphed into them with Walkers or ReMorphed, which is a front end for Walkers. Nothing is required for entities whose animations are driven only by Molang queries. Entities that choose their clips in their own tick, from MoveAnalysis, or from server-side AI need a small hook, described below.

Walkers is optional. AzureLib does not depend on it, and none of this runs unless it is installed.

Why morphs need help

Walkers draws a morph by keeping a client-side copy of the mob and copying the player's position, rotation, walk cycle and swing onto it every frame. The copy is never ticked and has no server-side counterpart, so on a morph copy:

  • server AI never runs, so flags it would set stay at their defaults and AzCommands it would send are never sent;
  • the entity's own client tick never runs, so clips chosen there are never chosen;
  • tickCount never advances, so animation LOD stops updating;
  • MoveAnalysis.update() is normally called from the entity's tick, so it never runs and isMoving() stays false.

Left alone, the morph stands in its idle (or first) clip and slides along with the player.

What AzureLib does for you

AzureLib hooks every entity type rendered by an AzEntityRenderer. For each morph copy it:

Every frame

  • copies the player's position and previous position onto the copy;
  • sets the copy's delta movement from how far the player moved, so query.ground_speed works, including for other players' morphs;
  • copies ground contact, sprinting and sneaking.

Once per player tick

  • advances tickCount, so animation LOD and ageInTicks keep working;
  • copies hurtTime;
  • calls your hook, if you have one.

If your animator only reads Molang queries (query.ground_speed, query.is_on_ground, query.limb_swing...), you are done.

AzureLib does not run your entity's tick(), so anything your tick normally does for animation, such as calling moveAnalysis.update() or choosing clips, needs a hook.

Quick start

There are two ways to drive a morph copy. Pick whichever fits how your entity already chooses its clips.

Entity picks its clips on the client: AzMorphable

If your entity chooses its clips in its own client tick, implement AzMorphable and call the same code:

public class Yautja extends Monster implements AzMorphable {
@Override
public void onMorphTick(AzMorphContext context) {
updateAirborneTicks();
dispatchBodyAnimations();
}
}

onMorphTick is only ever called on morph copies, never on a real mob, so it needs no checks. AzMorphable and AzMorphContext reference no client-only classes, so they are safe to implement on a common entity class.

Using MoveAnalysis

MoveAnalysis.update() is usually called from the entity's tick, which a morph copy never runs. Call it from onMorphTick before choosing clips:

public class XenomorphEntity extends AbstractAlienEntity implements AzMorphable {
@Override
public void onMorphTick(AzMorphContext context) {
moveAnalysis.update();
updateAnimations();
}
}

By the time onMorphTick runs, AzureLib has moved the copy to the player and advanced its tickCount, so update() sees a new tick and a new position, the same as on a real mob.

Server AI sets the flags your animator reads: a handler

If your animator reads synced data that server AI sets (moving, sprinting, attack type), register a handler from client setup and set those flags from the player:

AzMorphCompat.registerNamespace(MyMod.MOD_ID, (shape, context) -> {
if (shape instanceof MyMob mob) {
var moving = context.isMovingHorizontally();
mob.isMoving.set(moving);
mob.isRunning.set(moving && context.isSprinting());
}
});

registerNamespace covers every entity type in your mod and is resolved lazily, so it is safe to call before your entity types are registered (NeoForge runs client setup during mod construction). Use register(EntityType, handler) for a single type. A type handler takes priority over a namespace handler, which takes priority over AzMorphable.

Setting synced data on a morph copy is safe: the copy is client-side, so the change goes nowhere else.

State only the tick updates

Some vanilla state is only refreshed inside the entity's own tick, or is set by server AI. On a morph copy it stays at its default, so any clip that depends on it never plays. Set it from the player in onMorphTick:

@Override
public void onMorphTick(AzMorphContext context) {
var player = context.player();
// isInWater() reads a flag only the entity's tick refreshes.
this.wasTouchingWater = player.isInWater();
// isAggressive() is set by server AI; map it to sprinting for a run clip.
this.setAggressive(context.isSprinting());
moveAnalysis.update();
updateAnimations();
}
CheckWhy it is stuck on a copySet it from
isInWater()wasTouchingWater is refreshed in the entity's tick.player.isInWater()
isAggressive()Set by server AI.context.isSprinting(), or whatever suits your clip
moveAnalysis.isMoving()update() is called from the entity's tick.Call moveAnalysis.update()
Synced flags your AI setsServer AI never runs.Player state, as in the handler example above

Setting these on a morph copy is safe: the copy is client-side, so the change goes nowhere else.

Playing commands on a morph

Because the copy is client-side, sendForEntity dispatches straight to its animator with no packet. Your existing commands work as-is:

AzMorphCompat.registerNamespace(MyMod.MOD_ID, (shape, context) -> {
if (context.startedSwing()) {
MyAnimations.ATTACK.sendForEntity(shape);
}
});

The context

AzMorphContext describes the copy and the player wearing it for the current tick:

MethodReturns
player()The player wearing the morph.
shape()The morph copy.
morphTicks()Player ticks this copy has been driven for; 1 on the first tick.
deltaX() / deltaY() / deltaZ()How far the player moved this tick.
isMovingHorizontally()true above 0.01 blocks of horizontal travel this tick.
isSprinting() / isOnGround()The player's sprint and ground state.
startedSwing()true only on the tick the player's arm swing began.
state(key, factory)Per-copy scratch state for your handler, dropped with the copy.

Movement is measured from the player's position, not its delta movement. On the client, other players carry no delta movement of their own, so delta-based checks would leave their morphs standing still.

state is for things like cycling through attacks or timing when one ends. Use a private static key so mods never collide:

private static final Object ATTACK_KEY = new Object();
var attack = context.state(ATTACK_KEY, AttackState::new);

Skipping spawn animations

A morph copy runs onFirstAnimate like any new entity, which would play your spawn or emerge clip every time a player morphs. isMorphCopy is already true by then:

@Override
protected void onFirstAnimate(MyMob entity) {
if (AzMorphCompat.isMorphCopy(entity)) {
return;
}
MyAnimations.SPAWN.sendForEntity(entity);
}

Rules for handlers

  • Don't change size or pose. Walkers sizes the player from the morph copy. Changing something that affects the copy's dimensions on the client only, such as a crawling or swimming pose or a scale, desyncs the player's hitbox from the server.
  • Don't tick the copy. Calling tick() or aiStep() runs AI, movement and sounds on an entity that is not in the world. Call the specific methods that choose your clips instead.
  • Keep it cheap. Handlers run once per tick for every morphed player in view.

A handler that throws is logged once and disabled for that entity type. That type's morphs fall back to the default behavior above.

API reference

MethodEffect
AzMorphCompat.register(type, handler)Drive morph copies of one entity type.
AzMorphCompat.registerNamespace(namespace, handler)Drive morph copies of every type in a namespace.
AzMorphCompat.exclude(type)Keep AzureLib's hook off a type, e.g. one whose mod still handles Walkers itself.
AzMorphCompat.isMorphCopy(entity)Whether the entity is a morph copy.
AzMorphCompat.contextOf(entity)The copy's AzMorphContext, or null if it is not a morph copy.

Register handlers and exclusions from client setup. Types are hooked on the first client tick after entity renderers are created. A type that isn't rendered by an AzEntityRenderer is only hooked if a handler was registered for it by then.

Notes

  • Morph copies are never used for bone hitboxes. Their pose is not reported to the server, so morphing into an entity never moves a real mob's hitboxes.
  • If Walkers already has its own updater for a type, AzureLib keeps it and runs it before its own.
  • When Walkers is detected, the log shows how many types were hooked: Walkers detected: morph animations hooked for N AzureLib entity types. If N is 0, check that your entities use an AzEntityRenderer.