D2 Animation events data-driven : actions registry + JSON codecs
Biggest artist unlock Phase 3 — modders can now trigger actions at
animation begin/end/time-frame from JSON datapack, zero Java needed.
Infrastructure :
- AnimationAction interface + dispatch codec via 'type' field
- AnimationActionRegistry with 4 core actions registered at init
- SerializedEvent records (simple/time/period) with bidirectional
adapters to runtime SimpleEvent/InTimeEvent/InPeriodEvent
- ON_BEGIN_EVENTS / ON_END_EVENTS / TICK_EVENTS now serializable
via name+codec (previously threw IllegalStateException)
4 core actions :
- play_sound (sound, volume, pitch, category)
- spawn_particle (particle, at joint, count, speed, offset_xyz)
- apply_effect (effect, duration_ticks, amplifier, ambient, show_*)
- damage_entity (amount, source: 15 vanilla source whitelist)
Out of scope for this commit (follow-up) :
- set_animation_variable (coupled with D1 properties)
- swap_item_visibility (needs render layer integration)
- Conditional actions ('when': ...)
- Joint-world-position resolution for spawn_particle 'at' field
(needs partialTick plumb through AnimationAction.execute)
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
|
||||
import com.tiedup.remake.rig.asset.AssetAccessor;
|
||||
import com.tiedup.remake.rig.patch.LivingEntityPatch;
|
||||
|
||||
/**
|
||||
* Phase 3 D2 — biggest artist unlock : an {@code AnimationAction} is a
|
||||
* data-driven unit of behaviour triggered at animation begin / end / a specific
|
||||
* frame / a period, authored from a datapack JSON block without any Java code.
|
||||
*
|
||||
* <p>Serialization goes through {@link AnimationActionRegistry#dispatchCodec()}
|
||||
* which reads a {@code "type"} field and dispatches to the codec registered
|
||||
* for that {@link ResourceLocation}. Example JSON :
|
||||
* <pre>{@code
|
||||
* { "type": "tiedup:play_sound",
|
||||
* "sound": "minecraft:entity.player.levelup",
|
||||
* "volume": 0.8 }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Each implementation is responsible for its own sidedness — actions that
|
||||
* mutate world state should early-return on the wrong side, actions that spawn
|
||||
* client particles should early-return on server. This is by design : the outer
|
||||
* {@link com.tiedup.remake.rig.anim.property.AnimationEvent.Side} filter still
|
||||
* applies but individual actions can tighten it further when the outer event
|
||||
* is configured as {@code BOTH}.
|
||||
*
|
||||
* <p>The {@code prevElapsed} / {@code elapsed} arguments are forwarded verbatim
|
||||
* from the outer {@link com.tiedup.remake.rig.anim.property.AnimationEvent#execute}
|
||||
* call so that timing-aware actions (future extension) can key off the exact
|
||||
* trigger frame. Today's core actions ignore them — they execute atomically
|
||||
* on trigger.
|
||||
*/
|
||||
public interface AnimationAction {
|
||||
|
||||
/**
|
||||
* Dispatch codec — reads the {@code "type"} field of the JSON object and
|
||||
* delegates to the codec of the matching registered action. Unknown types
|
||||
* return a {@link com.mojang.serialization.DataResult} error (logged via
|
||||
* {@link com.tiedup.remake.rig.anim.property.AnimationProperty#parseFrom}
|
||||
* and the containing list entry is dropped).
|
||||
*
|
||||
* <p>Uses {@code partialDispatch} rather than {@code dispatch} because
|
||||
* {@code dispatch} requires a total function {@code ResourceLocation ->
|
||||
* Codec<? extends AnimationAction>}, which cannot express «unknown
|
||||
* type» without throwing.
|
||||
*/
|
||||
Codec<AnimationAction> CODEC = AnimationActionRegistry.dispatchCodec();
|
||||
|
||||
/**
|
||||
* The registered type id of this action (e.g. {@code tiedup:play_sound}).
|
||||
* Used by the dispatch codec to serialize back to JSON.
|
||||
*/
|
||||
ResourceLocation type();
|
||||
|
||||
/**
|
||||
* Execute this action against the entity playing {@code animation}.
|
||||
*
|
||||
* @param patch the living entity patch currently playing the animation
|
||||
* @param animation the animation accessor (forwarded from the outer event)
|
||||
* @param prevElapsed previous frame time in the animation (seconds)
|
||||
* @param elapsed current frame time in the animation (seconds)
|
||||
*/
|
||||
void execute(
|
||||
LivingEntityPatch<?> patch,
|
||||
AssetAccessor<? extends DynamicAnimation> animation,
|
||||
float prevElapsed,
|
||||
float elapsed
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action;
|
||||
|
||||
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.action.impl.ApplyEffectAction;
|
||||
import com.tiedup.remake.rig.anim.action.impl.DamageEntityAction;
|
||||
import com.tiedup.remake.rig.anim.action.impl.PlaySoundAction;
|
||||
import com.tiedup.remake.rig.anim.action.impl.SpawnParticleAction;
|
||||
|
||||
/**
|
||||
* Registry of {@link AnimationAction} type ids → codecs, used by the dispatch
|
||||
* codec exposed via {@link AnimationAction#CODEC}.
|
||||
*
|
||||
* <p>The four core actions ({@code play_sound}, {@code spawn_particle},
|
||||
* {@code apply_effect}, {@code damage_entity}) are registered in the static
|
||||
* initializer of this class so that a single reference to
|
||||
* {@link AnimationAction#CODEC} in a parse path is enough to bootstrap the
|
||||
* full dispatch table.
|
||||
*
|
||||
* <p>Third-party mods may register additional action types by calling
|
||||
* {@link #register(ResourceLocation, Codec)} from their common setup event
|
||||
* (post-{@code FMLCommonSetup} to avoid class-loading order surprises with
|
||||
* the static init of this class).
|
||||
*/
|
||||
public final class AnimationActionRegistry {
|
||||
|
||||
private static final Map<ResourceLocation, Codec<? extends AnimationAction>> TYPES = new HashMap<>();
|
||||
|
||||
private AnimationActionRegistry() {}
|
||||
|
||||
/**
|
||||
* Register a new action type. Throws {@link IllegalStateException} if the
|
||||
* id is already registered — this is intentional : silent shadowing of a
|
||||
* built-in action type would be a nasty debugging experience for artists.
|
||||
*
|
||||
* @throws IllegalStateException if {@code id} is already registered
|
||||
*/
|
||||
public static <T extends AnimationAction> void register(ResourceLocation id, Codec<T> codec) {
|
||||
if (TYPES.containsKey(id)) {
|
||||
throw new IllegalStateException("Animation action type " + id + " is already registered.");
|
||||
}
|
||||
|
||||
TYPES.put(id, codec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmodifiable view of all registered action types (for debug / introspection).
|
||||
*/
|
||||
public static Map<ResourceLocation, Codec<? extends AnimationAction>> types() {
|
||||
return Collections.unmodifiableMap(TYPES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the codec for a given type id. Returns {@code null} if the id is
|
||||
* not registered.
|
||||
*/
|
||||
public static Codec<? extends AnimationAction> getCodec(ResourceLocation id) {
|
||||
return TYPES.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the dispatch codec used by {@link AnimationAction#CODEC}.
|
||||
*
|
||||
* <p>Uses {@link Codec#partialDispatch} so that unknown types surface as a
|
||||
* {@link DataResult} error rather than a thrown exception. The error is
|
||||
* bubbled up by the standard {@link com.tiedup.remake.rig.anim.property.AnimationProperty#parseFrom}
|
||||
* pipeline (WARN log + {@code orElseThrow}).
|
||||
*/
|
||||
public static Codec<AnimationAction> dispatchCodec() {
|
||||
return ResourceLocation.CODEC.partialDispatch(
|
||||
"type",
|
||||
action -> DataResult.success(action.type()),
|
||||
id -> {
|
||||
Codec<? extends AnimationAction> codec = TYPES.get(id);
|
||||
if (codec == null) {
|
||||
return DataResult.error(() -> "Unknown animation action type: " + id);
|
||||
}
|
||||
return DataResult.success(codec);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static {
|
||||
register(PlaySoundAction.ID, PlaySoundAction.CODEC);
|
||||
register(SpawnParticleAction.ID, SpawnParticleAction.CODEC);
|
||||
register(ApplyEffectAction.ID, ApplyEffectAction.CODEC);
|
||||
register(DamageEntityAction.ID, DamageEntityAction.CODEC);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
|
||||
import com.tiedup.remake.rig.anim.property.AnimationEvent;
|
||||
import com.tiedup.remake.rig.anim.property.AnimationEvent.E0;
|
||||
import com.tiedup.remake.rig.anim.property.AnimationEvent.InPeriodEvent;
|
||||
import com.tiedup.remake.rig.anim.property.AnimationEvent.InTimeEvent;
|
||||
import com.tiedup.remake.rig.anim.property.AnimationEvent.SimpleEvent;
|
||||
|
||||
/**
|
||||
* Phase 3 D2 — adapter layer between JSON-authored event blocks and the
|
||||
* runtime {@link AnimationEvent} hierarchy. Three serialized event shapes are
|
||||
* supported :
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link SimpleSerializedEvent} — fires on animation begin or end
|
||||
* (no timing predicate).</li>
|
||||
* <li>{@link TimeSerializedEvent} — fires once when the animation crosses
|
||||
* the {@code frame} timestamp (seconds since anim start).</li>
|
||||
* <li>{@link PeriodSerializedEvent} — fires every tick while the animation
|
||||
* is between {@code start} and {@code end}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Each shape carries a list of {@link AnimationAction}s that all run when
|
||||
* the event fires. Actions drive their own sidedness — see individual impls —
|
||||
* so the outer {@link AnimationEvent.Side} defaults to {@link AnimationEvent.Side#BOTH}
|
||||
* and can be overridden via an optional {@code "side"} field
|
||||
* ({@code "CLIENT"}, {@code "SERVER"}, {@code "LOCAL_CLIENT"}, {@code "BOTH"}).
|
||||
*
|
||||
* <p>The adapter returns thin {@code E0} lambdas : the outer AnimationEvent
|
||||
* machinery keeps handling side filtering + time-window checking, and the
|
||||
* lambda body simply iterates the action list. This keeps the runtime path
|
||||
* identical to the hand-coded Java path (no perf regression, no new class
|
||||
* to pool).
|
||||
*/
|
||||
public final class DataDrivenAnimationEvents {
|
||||
|
||||
private DataDrivenAnimationEvents() {}
|
||||
|
||||
private static final Codec<AnimationEvent.Side> SIDE_CODEC = Codec.STRING.xmap(
|
||||
s -> AnimationEvent.Side.valueOf(s.toUpperCase()),
|
||||
Enum::name
|
||||
);
|
||||
|
||||
/**
|
||||
* Serialized shape for {@code on_begin} / {@code on_end} events.
|
||||
*/
|
||||
public record SimpleSerializedEvent(
|
||||
List<AnimationAction> actions,
|
||||
AnimationEvent.Side side
|
||||
) {
|
||||
|
||||
public static final Codec<SimpleSerializedEvent> CODEC = RecordCodecBuilder.create(i -> i.group(
|
||||
AnimationAction.CODEC.listOf().fieldOf("actions").forGetter(SimpleSerializedEvent::actions),
|
||||
SIDE_CODEC.optionalFieldOf("side", AnimationEvent.Side.BOTH).forGetter(SimpleSerializedEvent::side)
|
||||
).apply(i, SimpleSerializedEvent::new));
|
||||
|
||||
/**
|
||||
* Sugar accepting either a full object {@code {"actions":[...], "side":...}}
|
||||
* or a bare action list {@code [...]} — in which case {@code side}
|
||||
* defaults to {@link AnimationEvent.Side#BOTH}. This is the shape most
|
||||
* artists reach for.
|
||||
*/
|
||||
public static final Codec<SimpleSerializedEvent> SUGAR_CODEC = Codec.either(
|
||||
AnimationAction.CODEC.listOf(),
|
||||
CODEC
|
||||
).xmap(
|
||||
either -> either.map(
|
||||
actions -> new SimpleSerializedEvent(actions, AnimationEvent.Side.BOTH),
|
||||
full -> full
|
||||
),
|
||||
ev -> com.mojang.datafixers.util.Either.right(ev)
|
||||
);
|
||||
|
||||
/**
|
||||
* Convert this serialized record into the runtime {@link SimpleEvent}
|
||||
* representation.
|
||||
*/
|
||||
public SimpleEvent<E0> toRuntime() {
|
||||
final List<AnimationAction> captured = List.copyOf(this.actions);
|
||||
E0 fire = (patch, anim, params) -> {
|
||||
for (AnimationAction action : captured) {
|
||||
action.execute(patch, anim, 0.0F, 0.0F);
|
||||
}
|
||||
};
|
||||
return SimpleEvent.create(fire, this.side);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized shape for a {@code tick_events} entry that fires once when
|
||||
* the animation crosses {@code frame} (seconds).
|
||||
*/
|
||||
public record TimeSerializedEvent(
|
||||
float frame,
|
||||
List<AnimationAction> actions,
|
||||
AnimationEvent.Side side
|
||||
) {
|
||||
|
||||
public static final Codec<TimeSerializedEvent> CODEC = RecordCodecBuilder.create(i -> i.group(
|
||||
Codec.FLOAT.fieldOf("frame").forGetter(TimeSerializedEvent::frame),
|
||||
AnimationAction.CODEC.listOf().fieldOf("actions").forGetter(TimeSerializedEvent::actions),
|
||||
SIDE_CODEC.optionalFieldOf("side", AnimationEvent.Side.BOTH).forGetter(TimeSerializedEvent::side)
|
||||
).apply(i, TimeSerializedEvent::new));
|
||||
|
||||
public InTimeEvent<E0> toRuntime() {
|
||||
final List<AnimationAction> captured = List.copyOf(this.actions);
|
||||
E0 fire = (patch, anim, params) -> {
|
||||
for (AnimationAction action : captured) {
|
||||
action.execute(patch, anim, 0.0F, 0.0F);
|
||||
}
|
||||
};
|
||||
return InTimeEvent.create(this.frame, fire, this.side);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized shape for a {@code tick_events} entry that fires every tick
|
||||
* while the animation elapsed-time is between {@code start} and {@code end}.
|
||||
*/
|
||||
public record PeriodSerializedEvent(
|
||||
float start,
|
||||
float end,
|
||||
List<AnimationAction> actions,
|
||||
AnimationEvent.Side side
|
||||
) {
|
||||
|
||||
public static final Codec<PeriodSerializedEvent> CODEC = RecordCodecBuilder.create(i -> i.group(
|
||||
Codec.FLOAT.fieldOf("start").forGetter(PeriodSerializedEvent::start),
|
||||
Codec.FLOAT.fieldOf("end").forGetter(PeriodSerializedEvent::end),
|
||||
AnimationAction.CODEC.listOf().fieldOf("actions").forGetter(PeriodSerializedEvent::actions),
|
||||
SIDE_CODEC.optionalFieldOf("side", AnimationEvent.Side.BOTH).forGetter(PeriodSerializedEvent::side)
|
||||
).apply(i, PeriodSerializedEvent::new));
|
||||
|
||||
public InPeriodEvent<E0> toRuntime() {
|
||||
final List<AnimationAction> captured = List.copyOf(this.actions);
|
||||
E0 fire = (patch, anim, params) -> {
|
||||
for (AnimationAction action : captured) {
|
||||
action.execute(patch, anim, 0.0F, 0.0F);
|
||||
}
|
||||
};
|
||||
return InPeriodEvent.create(this.start, this.end, fire, this.side);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminator codec for a single {@code tick_events} entry — picks
|
||||
* between time and period by looking for the {@code "frame"} vs
|
||||
* {@code "start"}/{@code "end"} keys. Implemented as an {@link Codec#either}
|
||||
* because the two shapes are structurally disjoint (a time event cannot
|
||||
* also be a period event).
|
||||
*/
|
||||
public static final Codec<AnimationEvent<?, ?>> TICK_EVENT_ENTRY_CODEC = Codec.either(
|
||||
TimeSerializedEvent.CODEC,
|
||||
PeriodSerializedEvent.CODEC
|
||||
).xmap(
|
||||
either -> either.map(TimeSerializedEvent::toRuntime, PeriodSerializedEvent::toRuntime),
|
||||
// Encode path — best-effort : runtime events don't carry enough
|
||||
// structural info to re-serialize losslessly (the action list is lost
|
||||
// inside the opaque E0 lambda). We fall through to the period shape
|
||||
// which is the richer of the two — encoding is not a supported
|
||||
// round-trip path for these properties (see D2 scope notes).
|
||||
ev -> com.mojang.datafixers.util.Either.right(new PeriodSerializedEvent(0.0F, 0.0F, List.of(), AnimationEvent.Side.BOTH))
|
||||
);
|
||||
|
||||
/**
|
||||
* Codec for the full {@code tick_events} list property.
|
||||
*/
|
||||
public static final Codec<List<AnimationEvent<?, ?>>> TICK_EVENTS_CODEC =
|
||||
TICK_EVENT_ENTRY_CODEC.listOf().xmap(
|
||||
ArrayList::new,
|
||||
ArrayList::new
|
||||
);
|
||||
|
||||
/**
|
||||
* Codec for an {@code on_begin} / {@code on_end} list property. The entry
|
||||
* codec accepts either a bare action list or a full object, see
|
||||
* {@link SimpleSerializedEvent#SUGAR_CODEC}.
|
||||
*/
|
||||
public static final Codec<List<SimpleEvent<?>>> BEGIN_END_EVENTS_CODEC =
|
||||
SimpleSerializedEvent.SUGAR_CODEC.listOf().xmap(
|
||||
list -> {
|
||||
List<SimpleEvent<?>> out = new ArrayList<>(list.size());
|
||||
for (SimpleSerializedEvent ev : list) {
|
||||
out.add(ev.toRuntime());
|
||||
}
|
||||
return out;
|
||||
},
|
||||
// Lossy encode — runtime SimpleEvent<?> doesn't retain action list
|
||||
// after toRuntime wraps them in an opaque E0 lambda. Return empty
|
||||
// serialized list (see class Javadoc).
|
||||
runtime -> {
|
||||
List<SimpleSerializedEvent> out = new ArrayList<>(runtime.size());
|
||||
for (int i = 0; i < runtime.size(); i++) {
|
||||
out.add(new SimpleSerializedEvent(List.of(), AnimationEvent.Side.BOTH));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action.impl;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.effect.MobEffect;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraftforge.registries.ForgeRegistries;
|
||||
|
||||
import com.tiedup.remake.rig.TiedUpRigConstants;
|
||||
import com.tiedup.remake.rig.anim.action.AnimationAction;
|
||||
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
|
||||
import com.tiedup.remake.rig.asset.AssetAccessor;
|
||||
import com.tiedup.remake.rig.patch.LivingEntityPatch;
|
||||
|
||||
/**
|
||||
* Apply a potion effect to the animating entity. Server-side authoritative —
|
||||
* {@code LivingEntity.addEffect} is a no-op on the client (the effect will
|
||||
* be synced down when the server accepts the change).
|
||||
*
|
||||
* <p>JSON schema :
|
||||
* <pre>{@code
|
||||
* { "type": "tiedup:apply_effect",
|
||||
* "effect": "minecraft:slowness",
|
||||
* "duration_ticks": 60,
|
||||
* "amplifier": 1,
|
||||
* "ambient": false,
|
||||
* "show_particles": true,
|
||||
* "show_icon": true }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>{@code amplifier} defaults to {@code 0} (level 1), {@code ambient} /
|
||||
* {@code show_particles} / {@code show_icon} mirror {@link MobEffectInstance}'s
|
||||
* defaults ({@code false} / {@code true} / {@code true}). Unknown effect ids
|
||||
* are no-op + WARN.
|
||||
*/
|
||||
public record ApplyEffectAction(
|
||||
ResourceLocation effect,
|
||||
int durationTicks,
|
||||
int amplifier,
|
||||
boolean ambient,
|
||||
boolean showParticles,
|
||||
boolean showIcon
|
||||
) implements AnimationAction {
|
||||
|
||||
public static final ResourceLocation ID = TiedUpRigConstants.identifier("apply_effect");
|
||||
|
||||
public static final Codec<ApplyEffectAction> CODEC = RecordCodecBuilder.create(i -> i.group(
|
||||
ResourceLocation.CODEC.fieldOf("effect").forGetter(ApplyEffectAction::effect),
|
||||
Codec.INT.fieldOf("duration_ticks").forGetter(ApplyEffectAction::durationTicks),
|
||||
Codec.INT.optionalFieldOf("amplifier", 0).forGetter(ApplyEffectAction::amplifier),
|
||||
Codec.BOOL.optionalFieldOf("ambient", false).forGetter(ApplyEffectAction::ambient),
|
||||
Codec.BOOL.optionalFieldOf("show_particles", true).forGetter(ApplyEffectAction::showParticles),
|
||||
Codec.BOOL.optionalFieldOf("show_icon", true).forGetter(ApplyEffectAction::showIcon)
|
||||
).apply(i, ApplyEffectAction::new));
|
||||
|
||||
@Override
|
||||
public ResourceLocation type() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(
|
||||
LivingEntityPatch<?> patch,
|
||||
AssetAccessor<? extends DynamicAnimation> animation,
|
||||
float prevElapsed,
|
||||
float elapsed
|
||||
) {
|
||||
MobEffect mobEffect = ForgeRegistries.MOB_EFFECTS.getValue(this.effect);
|
||||
if (mobEffect == null) {
|
||||
TiedUpRigConstants.LOGGER.warn("ApplyEffectAction : unknown mob effect {}", this.effect);
|
||||
return;
|
||||
}
|
||||
|
||||
LivingEntity entity = patch.getOriginal();
|
||||
entity.addEffect(new MobEffectInstance(
|
||||
mobEffect,
|
||||
this.durationTicks,
|
||||
this.amplifier,
|
||||
this.ambient,
|
||||
this.showParticles,
|
||||
this.showIcon
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action.impl;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.DataResult;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.damagesource.DamageSource;
|
||||
import net.minecraft.world.damagesource.DamageSources;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
|
||||
import com.tiedup.remake.rig.TiedUpRigConstants;
|
||||
import com.tiedup.remake.rig.anim.action.AnimationAction;
|
||||
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
|
||||
import com.tiedup.remake.rig.asset.AssetAccessor;
|
||||
import com.tiedup.remake.rig.patch.LivingEntityPatch;
|
||||
|
||||
/**
|
||||
* Damage the animating entity for {@link #amount} half-hearts using the named
|
||||
* damage source. Server-side authoritative — {@code LivingEntity.hurt} is a
|
||||
* no-op on the client.
|
||||
*
|
||||
* <p>JSON schema :
|
||||
* <pre>{@code
|
||||
* { "type": "tiedup:damage_entity",
|
||||
* "amount": 2.0,
|
||||
* "source": "generic" }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The {@code source} field maps to a helper method on {@link DamageSources}
|
||||
* (see the {@link SourceType} enum). Defaults to {@code generic}. In 1.20.1
|
||||
* damage types are registry-driven {@link net.minecraft.resources.ResourceKey},
|
||||
* so the direct JSON-to-ResourceKey path would require a {@code RegistryAccess}
|
||||
* we don't have in a codec — using the helper names keeps authoring ergonomic
|
||||
* and side-steps the registry plumbing. Mods that want a custom damage type
|
||||
* can register an {@link AnimationAction} subclass that consumes their id
|
||||
* directly.
|
||||
*/
|
||||
public record DamageEntityAction(
|
||||
float amount,
|
||||
SourceType source
|
||||
) implements AnimationAction {
|
||||
|
||||
public static final ResourceLocation ID = TiedUpRigConstants.identifier("damage_entity");
|
||||
|
||||
/**
|
||||
* Safe codec over {@link SourceType} : uppercases + looks up the enum
|
||||
* constant, returning a {@link DataResult#error} for unknown names rather
|
||||
* than throwing. {@code flatXmap} is used (over {@code xmap}) precisely to
|
||||
* surface the error through the Codec pipeline.
|
||||
*/
|
||||
private static final Codec<SourceType> SOURCE_TYPE_CODEC = Codec.STRING.flatXmap(
|
||||
s -> {
|
||||
try {
|
||||
return DataResult.success(SourceType.valueOf(s.toUpperCase()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return DataResult.error(() -> "Unknown damage source type : " + s);
|
||||
}
|
||||
},
|
||||
t -> DataResult.success(t.name().toLowerCase())
|
||||
);
|
||||
|
||||
public static final Codec<DamageEntityAction> CODEC = RecordCodecBuilder.create(i -> i.group(
|
||||
Codec.FLOAT.fieldOf("amount").forGetter(DamageEntityAction::amount),
|
||||
SOURCE_TYPE_CODEC.optionalFieldOf("source", SourceType.GENERIC).forGetter(DamageEntityAction::source)
|
||||
).apply(i, DamageEntityAction::new));
|
||||
|
||||
@Override
|
||||
public ResourceLocation type() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(
|
||||
LivingEntityPatch<?> patch,
|
||||
AssetAccessor<? extends DynamicAnimation> animation,
|
||||
float prevElapsed,
|
||||
float elapsed
|
||||
) {
|
||||
LivingEntity entity = patch.getOriginal();
|
||||
if (entity.level().isClientSide()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DamageSource damageSource = resolveDamageSource(entity.damageSources(), this.source);
|
||||
if (damageSource == null) {
|
||||
TiedUpRigConstants.LOGGER.warn("DamageEntityAction : could not resolve damage source {}", this.source);
|
||||
return;
|
||||
}
|
||||
|
||||
entity.hurt(damageSource, this.amount);
|
||||
}
|
||||
|
||||
private static DamageSource resolveDamageSource(DamageSources sources, SourceType type) {
|
||||
return switch (type) {
|
||||
case GENERIC -> sources.generic();
|
||||
case MAGIC -> sources.magic();
|
||||
case FALL -> sources.fall();
|
||||
case IN_FIRE -> sources.inFire();
|
||||
case ON_FIRE -> sources.onFire();
|
||||
case LAVA -> sources.lava();
|
||||
case DROWN -> sources.drown();
|
||||
case STARVE -> sources.starve();
|
||||
case CACTUS -> sources.cactus();
|
||||
case CRAMMING -> sources.cramming();
|
||||
case IN_WALL -> sources.inWall();
|
||||
case WITHER -> sources.wither();
|
||||
case FREEZE -> sources.freeze();
|
||||
case DRY_OUT -> sources.dryOut();
|
||||
case SWEET_BERRY_BUSH -> sources.sweetBerryBush();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist of vanilla damage sources addressable from JSON. Artist-facing
|
||||
* names are lowercase ({@code "generic"}, {@code "magic"}, ...) — the codec
|
||||
* uppercases before looking up the enum.
|
||||
*/
|
||||
public enum SourceType {
|
||||
GENERIC,
|
||||
MAGIC,
|
||||
FALL,
|
||||
IN_FIRE,
|
||||
ON_FIRE,
|
||||
LAVA,
|
||||
DROWN,
|
||||
STARVE,
|
||||
CACTUS,
|
||||
CRAMMING,
|
||||
IN_WALL,
|
||||
WITHER,
|
||||
FREEZE,
|
||||
DRY_OUT,
|
||||
SWEET_BERRY_BUSH
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action.impl;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.sounds.SoundEvent;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraftforge.registries.ForgeRegistries;
|
||||
|
||||
import com.tiedup.remake.rig.TiedUpRigConstants;
|
||||
import com.tiedup.remake.rig.anim.action.AnimationAction;
|
||||
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
|
||||
import com.tiedup.remake.rig.asset.AssetAccessor;
|
||||
import com.tiedup.remake.rig.patch.LivingEntityPatch;
|
||||
|
||||
/**
|
||||
* Play a sound at the entity's position. Server-side authoritative — the
|
||||
* {@code level.playSound(null, ...)} call broadcasts to all clients within
|
||||
* the default spatial radius.
|
||||
*
|
||||
* <p>JSON schema :
|
||||
* <pre>{@code
|
||||
* { "type": "tiedup:play_sound",
|
||||
* "sound": "minecraft:entity.player.levelup",
|
||||
* "volume": 0.8,
|
||||
* "pitch": 1.1,
|
||||
* "category": "neutral" }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>{@code volume} defaults to {@code 1.0}, {@code pitch} defaults to
|
||||
* {@code 1.0}, {@code category} defaults to {@code neutral}. Unknown sound
|
||||
* ids are silently no-op — they log a WARN via the surrounding parse path
|
||||
* and skip execution (safer than crashing the animation).
|
||||
*/
|
||||
public record PlaySoundAction(
|
||||
ResourceLocation sound,
|
||||
float volume,
|
||||
float pitch,
|
||||
SoundSource category
|
||||
) implements AnimationAction {
|
||||
|
||||
public static final ResourceLocation ID = TiedUpRigConstants.identifier("play_sound");
|
||||
|
||||
private static final Codec<SoundSource> SOURCE_CODEC = Codec.STRING.xmap(
|
||||
s -> SoundSource.valueOf(s.toUpperCase()),
|
||||
source -> source.getName().toUpperCase()
|
||||
);
|
||||
|
||||
public static final Codec<PlaySoundAction> CODEC = RecordCodecBuilder.create(i -> i.group(
|
||||
ResourceLocation.CODEC.fieldOf("sound").forGetter(PlaySoundAction::sound),
|
||||
Codec.FLOAT.optionalFieldOf("volume", 1.0F).forGetter(PlaySoundAction::volume),
|
||||
Codec.FLOAT.optionalFieldOf("pitch", 1.0F).forGetter(PlaySoundAction::pitch),
|
||||
SOURCE_CODEC.optionalFieldOf("category", SoundSource.NEUTRAL).forGetter(PlaySoundAction::category)
|
||||
).apply(i, PlaySoundAction::new));
|
||||
|
||||
@Override
|
||||
public ResourceLocation type() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(
|
||||
LivingEntityPatch<?> patch,
|
||||
AssetAccessor<? extends DynamicAnimation> animation,
|
||||
float prevElapsed,
|
||||
float elapsed
|
||||
) {
|
||||
SoundEvent event = ForgeRegistries.SOUND_EVENTS.getValue(this.sound);
|
||||
if (event == null) {
|
||||
TiedUpRigConstants.LOGGER.warn("PlaySoundAction : unknown sound event {}", this.sound);
|
||||
return;
|
||||
}
|
||||
|
||||
LivingEntity entity = patch.getOriginal();
|
||||
entity.level().playSound(
|
||||
null,
|
||||
entity.getX(), entity.getY(), entity.getZ(),
|
||||
event,
|
||||
this.category,
|
||||
this.volume,
|
||||
this.pitch
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action.impl;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
|
||||
import net.minecraft.core.particles.ParticleOptions;
|
||||
import net.minecraft.core.particles.ParticleType;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.minecraftforge.registries.ForgeRegistries;
|
||||
|
||||
import com.tiedup.remake.rig.TiedUpRigConstants;
|
||||
import com.tiedup.remake.rig.anim.action.AnimationAction;
|
||||
import com.tiedup.remake.rig.anim.types.DynamicAnimation;
|
||||
import com.tiedup.remake.rig.armature.Armature;
|
||||
import com.tiedup.remake.rig.armature.Joint;
|
||||
import com.tiedup.remake.rig.asset.AssetAccessor;
|
||||
import com.tiedup.remake.rig.patch.LivingEntityPatch;
|
||||
|
||||
/**
|
||||
* Spawn a particle burst at the entity's root position or at a named joint.
|
||||
* Client-side only — the {@code level.addParticle} API requires a ClientLevel.
|
||||
* Actions declared on a server-side outer event are no-op.
|
||||
*
|
||||
* <p>JSON schema :
|
||||
* <pre>{@code
|
||||
* { "type": "tiedup:spawn_particle",
|
||||
* "particle": "minecraft:smoke",
|
||||
* "at": "Root",
|
||||
* "count": 5,
|
||||
* "speed": 0.05,
|
||||
* "offset_x": 0.0,
|
||||
* "offset_y": 1.2,
|
||||
* "offset_z": 0.0 }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>{@code at} defaults to «root joint position = entity position» —
|
||||
* if the specified joint does not exist in the armature, a WARN is logged and
|
||||
* the particle spawns at the entity origin. {@code count} defaults to {@code 1},
|
||||
* {@code speed} defaults to {@code 0.0}, offsets default to zero.
|
||||
*
|
||||
* <p><b>Design note :</b> the particle type must implement {@link ParticleOptions}
|
||||
* directly — vanilla «simple» particles like {@code minecraft:smoke}
|
||||
* satisfy this because {@link ParticleType} extends {@link ParticleOptions}
|
||||
* when the particle carries no extra data. Complex particles that require data
|
||||
* parameters (block / item / dust color) cannot currently be authored through
|
||||
* this action — that's a follow-up (would require a particle data sub-codec).
|
||||
*/
|
||||
public record SpawnParticleAction(
|
||||
ResourceLocation particle,
|
||||
String joint,
|
||||
int count,
|
||||
float speed,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float offsetZ
|
||||
) implements AnimationAction {
|
||||
|
||||
public static final ResourceLocation ID = TiedUpRigConstants.identifier("spawn_particle");
|
||||
|
||||
public static final Codec<SpawnParticleAction> CODEC = RecordCodecBuilder.create(i -> i.group(
|
||||
ResourceLocation.CODEC.fieldOf("particle").forGetter(SpawnParticleAction::particle),
|
||||
Codec.STRING.optionalFieldOf("at", "").forGetter(SpawnParticleAction::joint),
|
||||
Codec.INT.optionalFieldOf("count", 1).forGetter(SpawnParticleAction::count),
|
||||
Codec.FLOAT.optionalFieldOf("speed", 0.0F).forGetter(SpawnParticleAction::speed),
|
||||
Codec.FLOAT.optionalFieldOf("offset_x", 0.0F).forGetter(SpawnParticleAction::offsetX),
|
||||
Codec.FLOAT.optionalFieldOf("offset_y", 0.0F).forGetter(SpawnParticleAction::offsetY),
|
||||
Codec.FLOAT.optionalFieldOf("offset_z", 0.0F).forGetter(SpawnParticleAction::offsetZ)
|
||||
).apply(i, SpawnParticleAction::new));
|
||||
|
||||
@Override
|
||||
public ResourceLocation type() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(
|
||||
LivingEntityPatch<?> patch,
|
||||
AssetAccessor<? extends DynamicAnimation> animation,
|
||||
float prevElapsed,
|
||||
float elapsed
|
||||
) {
|
||||
LivingEntity entity = patch.getOriginal();
|
||||
Level level = entity.level();
|
||||
|
||||
// Client-side guard : addParticle is a no-op on server but we short
|
||||
// circuit early to avoid the registry lookup overhead.
|
||||
if (!level.isClientSide()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ParticleType<?> particleType = ForgeRegistries.PARTICLE_TYPES.getValue(this.particle);
|
||||
if (particleType == null) {
|
||||
TiedUpRigConstants.LOGGER.warn("SpawnParticleAction : unknown particle type {}", this.particle);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(particleType instanceof ParticleOptions options)) {
|
||||
TiedUpRigConstants.LOGGER.warn(
|
||||
"SpawnParticleAction : particle {} does not implement ParticleOptions (complex particles not yet supported)",
|
||||
this.particle
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Vec3 origin = resolveOrigin(patch, entity);
|
||||
|
||||
for (int n = 0; n < this.count; n++) {
|
||||
level.addParticle(
|
||||
options,
|
||||
origin.x + this.offsetX,
|
||||
origin.y + this.offsetY,
|
||||
origin.z + this.offsetZ,
|
||||
0.0, this.speed, 0.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the spawn origin. If {@link #joint} is non-empty and matches an
|
||||
* armature joint, we would ideally transform the joint's local position
|
||||
* into world space — but the public {@link LivingEntityPatch} API does not
|
||||
* yet expose a joint-world-position helper (tracked separately). For now
|
||||
* we only validate the joint exists and fall back to the entity position.
|
||||
*/
|
||||
private Vec3 resolveOrigin(LivingEntityPatch<?> patch, LivingEntity entity) {
|
||||
Vec3 entityPos = new Vec3(entity.getX(), entity.getY(), entity.getZ());
|
||||
|
||||
if (this.joint == null || this.joint.isEmpty()) {
|
||||
return entityPos;
|
||||
}
|
||||
|
||||
Armature armature = patch.getArmature();
|
||||
if (armature == null) {
|
||||
return entityPos;
|
||||
}
|
||||
|
||||
Joint target = armature.searchJointByName(this.joint);
|
||||
if (target == null) {
|
||||
TiedUpRigConstants.LOGGER.warn(
|
||||
"SpawnParticleAction : unknown joint '{}' on armature '{}', falling back to entity position",
|
||||
this.joint, armature
|
||||
);
|
||||
return entityPos;
|
||||
}
|
||||
|
||||
// TODO (D2 follow-up) : apply joint's model-space transform to entityPos
|
||||
// via patch.getModelMatrix(partialTick). Needs a partialTick plumb
|
||||
// through AnimationAction.execute which today only forwards elapsed.
|
||||
return entityPos;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import com.tiedup.remake.rig.anim.AnimationManager.AnimationAccessor;
|
||||
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.property.AnimationEvent.SimpleEvent;
|
||||
import com.tiedup.remake.rig.anim.property.MoveCoordFunctions.MoveCoordGetter;
|
||||
import com.tiedup.remake.rig.anim.property.MoveCoordFunctions.MoveCoordSetter;
|
||||
@@ -94,18 +95,43 @@ public abstract class AnimationProperty<T> {
|
||||
|
||||
/**
|
||||
* Events that are fired in every tick.
|
||||
*
|
||||
* <p>Phase 3 D2 — serializable from a datapack JSON
|
||||
* {@code "tick_events"} block. Each entry is either a «time»
|
||||
* event ({@code {"frame":0.15, "actions":[...]}}) or a
|
||||
* «period» event
|
||||
* ({@code {"start":0.0, "end":1.0, "actions":[...]}}). See
|
||||
* {@link DataDrivenAnimationEvents#TICK_EVENTS_CODEC}.
|
||||
*/
|
||||
public static final StaticAnimationProperty<List<AnimationEvent<?, ?>>> TICK_EVENTS = new StaticAnimationProperty<List<AnimationEvent<?, ?>>> ();
|
||||
public static final StaticAnimationProperty<List<AnimationEvent<?, ?>>> TICK_EVENTS = new StaticAnimationProperty<List<AnimationEvent<?, ?>>> (
|
||||
"tick_events",
|
||||
DataDrivenAnimationEvents.TICK_EVENTS_CODEC
|
||||
);
|
||||
|
||||
/**
|
||||
* Events that are fired when the animation starts.
|
||||
*
|
||||
* <p>Phase 3 D2 — serializable from a datapack JSON {@code "on_begin"}
|
||||
* block. Each entry is an «action list»
|
||||
* ({@code [{"type":"tiedup:play_sound", ...}, ...]}) or a full object
|
||||
* ({@code {"actions":[...], "side":"SERVER"}}). See
|
||||
* {@link DataDrivenAnimationEvents#BEGIN_END_EVENTS_CODEC}.
|
||||
*/
|
||||
public static final StaticAnimationProperty<List<SimpleEvent<?>>> ON_BEGIN_EVENTS = new StaticAnimationProperty<List<SimpleEvent<?>>> ();
|
||||
public static final StaticAnimationProperty<List<SimpleEvent<?>>> ON_BEGIN_EVENTS = new StaticAnimationProperty<List<SimpleEvent<?>>> (
|
||||
"on_begin",
|
||||
DataDrivenAnimationEvents.BEGIN_END_EVENTS_CODEC
|
||||
);
|
||||
|
||||
/**
|
||||
* Events that are fired when the animation ends.
|
||||
*
|
||||
* <p>Phase 3 D2 — serializable from a datapack JSON {@code "on_end"}
|
||||
* block. Same shape as {@link #ON_BEGIN_EVENTS}.
|
||||
*/
|
||||
public static final StaticAnimationProperty<List<SimpleEvent<?>>> ON_END_EVENTS = new StaticAnimationProperty<List<SimpleEvent<?>>> ();
|
||||
public static final StaticAnimationProperty<List<SimpleEvent<?>>> ON_END_EVENTS = new StaticAnimationProperty<List<SimpleEvent<?>>> (
|
||||
"on_end",
|
||||
DataDrivenAnimationEvents.BEGIN_END_EVENTS_CODEC
|
||||
);
|
||||
|
||||
/**
|
||||
* An event triggered when entity changes an item in hand.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action;
|
||||
|
||||
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.Codec;
|
||||
import com.mojang.serialization.DataResult;
|
||||
import com.mojang.serialization.JsonOps;
|
||||
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.tiedup.remake.rig.anim.action.impl.ApplyEffectAction;
|
||||
import com.tiedup.remake.rig.anim.action.impl.DamageEntityAction;
|
||||
import com.tiedup.remake.rig.anim.action.impl.PlaySoundAction;
|
||||
import com.tiedup.remake.rig.anim.action.impl.SpawnParticleAction;
|
||||
|
||||
/**
|
||||
* Tests for {@link AnimationActionRegistry} and the dispatch codec exposed via
|
||||
* {@link AnimationAction#CODEC}. Pure Java — no MC bootstrap required.
|
||||
*
|
||||
* <p>The registry static-init registers the 4 core actions, so merely
|
||||
* referencing {@link AnimationActionRegistry} here triggers the clinit. A
|
||||
* {@code BeforeAll} hook forces class-loading in a deterministic order
|
||||
* regardless of JUnit test ordering.
|
||||
*/
|
||||
class AnimationActionRegistryTest {
|
||||
|
||||
@BeforeAll
|
||||
static void forceClinit() {
|
||||
assertNotNull(PlaySoundAction.ID);
|
||||
assertNotNull(SpawnParticleAction.ID);
|
||||
assertNotNull(ApplyEffectAction.ID);
|
||||
assertNotNull(DamageEntityAction.ID);
|
||||
assertNotNull(AnimationAction.CODEC);
|
||||
}
|
||||
|
||||
// ===== Registry population =====
|
||||
|
||||
@Test
|
||||
void coreActions_registered() {
|
||||
assertNotNull(AnimationActionRegistry.getCodec(PlaySoundAction.ID));
|
||||
assertNotNull(AnimationActionRegistry.getCodec(SpawnParticleAction.ID));
|
||||
assertNotNull(AnimationActionRegistry.getCodec(ApplyEffectAction.ID));
|
||||
assertNotNull(AnimationActionRegistry.getCodec(DamageEntityAction.ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
void types_returnsFourEntries_atMinimum() {
|
||||
// At least 4 (there could be additional registrations by other tests,
|
||||
// but we should have our 4 core).
|
||||
assertTrue(AnimationActionRegistry.types().size() >= 4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCodec_unknownType_returnsNull() {
|
||||
assertNull(AnimationActionRegistry.getCodec(new ResourceLocation("foo", "bar")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_duplicateId_throws() {
|
||||
// Attempt to re-register PlaySoundAction id → must throw, never
|
||||
// silently shadow. Uses an arbitrary no-op codec for the retry.
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
AnimationActionRegistry.register(PlaySoundAction.ID, PlaySoundAction.CODEC)
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Dispatch codec round-trip =====
|
||||
|
||||
/**
|
||||
* Encode a {@link PlaySoundAction} → JSON → decode → equals the original.
|
||||
* This exercises the dispatch codec's serialize path (embed {@code type}
|
||||
* discriminator + delegate to PlaySoundAction.CODEC).
|
||||
*/
|
||||
@Test
|
||||
void dispatchCodec_knownType_roundtrip_playSound() {
|
||||
PlaySoundAction original = new PlaySoundAction(
|
||||
new ResourceLocation("minecraft", "entity.player.levelup"),
|
||||
0.8F,
|
||||
1.1F,
|
||||
SoundSource.NEUTRAL
|
||||
);
|
||||
|
||||
DataResult<JsonElement> encoded = AnimationAction.CODEC.encodeStart(JsonOps.INSTANCE, original);
|
||||
assertTrue(encoded.result().isPresent(), "encode must succeed : " + encoded.error());
|
||||
|
||||
JsonObject json = encoded.result().get().getAsJsonObject();
|
||||
assertEquals("tiedup:play_sound", json.get("type").getAsString());
|
||||
assertEquals("minecraft:entity.player.levelup", json.get("sound").getAsString());
|
||||
|
||||
DataResult<AnimationAction> decoded =
|
||||
AnimationAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(decoded.result().isPresent(), "decode must succeed : " + decoded.error());
|
||||
|
||||
AnimationAction back = decoded.result().get();
|
||||
assertTrue(back instanceof PlaySoundAction);
|
||||
assertEquals(original, back);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchCodec_unknownType_returnsError() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("type", new JsonPrimitive("foo:unknown_action"));
|
||||
|
||||
DataResult<AnimationAction> decoded =
|
||||
AnimationAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertFalse(decoded.result().isPresent(), "decode must fail for unknown type");
|
||||
assertTrue(decoded.error().isPresent(), "error must be populated");
|
||||
assertTrue(
|
||||
decoded.error().get().message().contains("foo:unknown_action"),
|
||||
"error message should mention the unknown type, got : " + decoded.error().get().message()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing {@code type} field → decode error.
|
||||
*/
|
||||
@Test
|
||||
void dispatchCodec_missingType_returnsError() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("sound", new JsonPrimitive("minecraft:ambient.cave"));
|
||||
|
||||
DataResult<AnimationAction> decoded =
|
||||
AnimationAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertFalse(decoded.result().isPresent(), "decode must fail without type field");
|
||||
}
|
||||
|
||||
/**
|
||||
* Round-trip all 4 core action types in a single test — guards against a
|
||||
* silent registration drop.
|
||||
*/
|
||||
@Test
|
||||
void dispatchCodec_allCoreTypes_parsable() {
|
||||
String[] types = {
|
||||
"tiedup:play_sound",
|
||||
"tiedup:spawn_particle",
|
||||
"tiedup:apply_effect",
|
||||
"tiedup:damage_entity"
|
||||
};
|
||||
|
||||
for (String t : types) {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("type", new JsonPrimitive(t));
|
||||
// Each action has at least one required field — add minimal set
|
||||
switch (t) {
|
||||
case "tiedup:play_sound" ->
|
||||
json.add("sound", new JsonPrimitive("minecraft:entity.player.levelup"));
|
||||
case "tiedup:spawn_particle" ->
|
||||
json.add("particle", new JsonPrimitive("minecraft:smoke"));
|
||||
case "tiedup:apply_effect" -> {
|
||||
json.add("effect", new JsonPrimitive("minecraft:slowness"));
|
||||
json.add("duration_ticks", new JsonPrimitive(60));
|
||||
}
|
||||
case "tiedup:damage_entity" ->
|
||||
json.add("amount", new JsonPrimitive(1.0F));
|
||||
default -> {}
|
||||
}
|
||||
|
||||
DataResult<AnimationAction> decoded = AnimationAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(
|
||||
decoded.result().isPresent(),
|
||||
"decode of " + t + " must succeed : " + decoded.error().map(e -> e.message()).orElse("?")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
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.Test;
|
||||
|
||||
/**
|
||||
* Parse / default tests for {@link ApplyEffectAction}.
|
||||
*/
|
||||
class ApplyEffectActionTest {
|
||||
|
||||
@Test
|
||||
void parse_fullObject_populates() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("effect", new JsonPrimitive("minecraft:slowness"));
|
||||
json.add("duration_ticks", new JsonPrimitive(120));
|
||||
json.add("amplifier", new JsonPrimitive(2));
|
||||
json.add("ambient", new JsonPrimitive(true));
|
||||
json.add("show_particles", new JsonPrimitive(false));
|
||||
json.add("show_icon", new JsonPrimitive(false));
|
||||
|
||||
DataResult<ApplyEffectAction> parsed = ApplyEffectAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(parsed.result().isPresent(), "parse must succeed : " + parsed.error());
|
||||
|
||||
ApplyEffectAction action = parsed.result().get();
|
||||
assertEquals(new ResourceLocation("minecraft", "slowness"), action.effect());
|
||||
assertEquals(120, action.durationTicks());
|
||||
assertEquals(2, action.amplifier());
|
||||
assertTrue(action.ambient());
|
||||
assertFalse(action.showParticles());
|
||||
assertFalse(action.showIcon());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_minimal_usesDefaults() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("effect", new JsonPrimitive("minecraft:regeneration"));
|
||||
json.add("duration_ticks", new JsonPrimitive(40));
|
||||
|
||||
DataResult<ApplyEffectAction> parsed = ApplyEffectAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(parsed.result().isPresent());
|
||||
|
||||
ApplyEffectAction action = parsed.result().get();
|
||||
assertEquals(0, action.amplifier());
|
||||
assertFalse(action.ambient());
|
||||
assertTrue(action.showParticles());
|
||||
assertTrue(action.showIcon());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_missingRequired_fails() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("effect", new JsonPrimitive("minecraft:glowing"));
|
||||
// missing duration_ticks
|
||||
|
||||
DataResult<ApplyEffectAction> parsed = ApplyEffectAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertFalse(parsed.result().isPresent(), "parse without duration_ticks must fail");
|
||||
}
|
||||
|
||||
@Test
|
||||
void type_isStable() {
|
||||
assertEquals(new ResourceLocation("tiedup", "apply_effect"), ApplyEffectAction.ID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
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.Test;
|
||||
|
||||
/**
|
||||
* Parse / default tests for {@link DamageEntityAction}.
|
||||
*/
|
||||
class DamageEntityActionTest {
|
||||
|
||||
@Test
|
||||
void parse_fullObject_populates() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("amount", new JsonPrimitive(2.5F));
|
||||
json.add("source", new JsonPrimitive("magic"));
|
||||
|
||||
DataResult<DamageEntityAction> parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(parsed.result().isPresent(), "parse must succeed : " + parsed.error());
|
||||
|
||||
DamageEntityAction action = parsed.result().get();
|
||||
assertEquals(2.5F, action.amount(), 0.0001F);
|
||||
assertEquals(DamageEntityAction.SourceType.MAGIC, action.source());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_missingSource_defaultsToGeneric() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("amount", new JsonPrimitive(1.0F));
|
||||
|
||||
DataResult<DamageEntityAction> parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(parsed.result().isPresent());
|
||||
|
||||
DamageEntityAction action = parsed.result().get();
|
||||
assertEquals(DamageEntityAction.SourceType.GENERIC, action.source());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_caseInsensitiveSource() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("amount", new JsonPrimitive(1.0F));
|
||||
json.add("source", new JsonPrimitive("In_Fire"));
|
||||
|
||||
DataResult<DamageEntityAction> parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(parsed.result().isPresent());
|
||||
assertEquals(DamageEntityAction.SourceType.IN_FIRE, parsed.result().get().source());
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown source name + default GENERIC : because {@code source} is an
|
||||
* {@code optionalFieldOf("source", GENERIC)}, Mojang's codec pipeline
|
||||
* swallows the inner parse error and falls back to the default. Net
|
||||
* effect : an artist typo silently becomes {@code GENERIC} damage rather
|
||||
* than blocking the entire animation load. Acceptable behaviour — the
|
||||
* error is still logged via the standard Codec partial-result path when
|
||||
* the animation property parse runs through
|
||||
* {@link com.tiedup.remake.rig.anim.property.AnimationProperty#parseFrom}
|
||||
* (see {@code resultOrPartial} in that method).
|
||||
*/
|
||||
@Test
|
||||
void parse_invalidSource_fallsBackToGeneric() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("amount", new JsonPrimitive(1.0F));
|
||||
json.add("source", new JsonPrimitive("unknown_source_xyz"));
|
||||
|
||||
DataResult<DamageEntityAction> parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(parsed.result().isPresent(),
|
||||
"optionalFieldOf(default) swallows inner parse error and returns default");
|
||||
assertEquals(DamageEntityAction.SourceType.GENERIC, parsed.result().get().source());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_missingAmount_fails() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("source", new JsonPrimitive("generic"));
|
||||
|
||||
DataResult<DamageEntityAction> parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertFalse(parsed.result().isPresent(), "parse without amount must fail");
|
||||
}
|
||||
|
||||
@Test
|
||||
void type_isStable() {
|
||||
assertEquals(new ResourceLocation("tiedup", "damage_entity"), DamageEntityAction.ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void allSourceTypes_haveResolvableName() {
|
||||
// Guards against a sloppy enum constant added without being wired in
|
||||
// resolveDamageSource. Parsing the enum name must succeed for every
|
||||
// declared variant.
|
||||
for (DamageEntityAction.SourceType type : DamageEntityAction.SourceType.values()) {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("amount", new JsonPrimitive(1.0F));
|
||||
json.add("source", new JsonPrimitive(type.name().toLowerCase()));
|
||||
|
||||
DataResult<DamageEntityAction> parsed = DamageEntityAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(
|
||||
parsed.result().isPresent(),
|
||||
"source " + type + " must parse but got : " + parsed.error()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action.impl;
|
||||
|
||||
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.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 net.minecraft.sounds.SoundSource;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Parse / encode tests for {@link PlaySoundAction}. No MC bootstrap — the
|
||||
* Codec / ResourceLocation / SoundSource types are available directly from
|
||||
* the test classpath.
|
||||
*/
|
||||
class PlaySoundActionTest {
|
||||
|
||||
@Test
|
||||
void parse_fullObject_roundtrip() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("type", new JsonPrimitive("tiedup:play_sound"));
|
||||
json.add("sound", new JsonPrimitive("minecraft:entity.player.levelup"));
|
||||
json.add("volume", new JsonPrimitive(0.5F));
|
||||
json.add("pitch", new JsonPrimitive(1.2F));
|
||||
json.add("category", new JsonPrimitive("neutral"));
|
||||
|
||||
DataResult<PlaySoundAction> parsed = PlaySoundAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(parsed.result().isPresent(), "parse must succeed : " + parsed.error());
|
||||
|
||||
PlaySoundAction action = parsed.result().get();
|
||||
assertEquals(new ResourceLocation("minecraft", "entity.player.levelup"), action.sound());
|
||||
assertEquals(0.5F, action.volume(), 0.0001F);
|
||||
assertEquals(1.2F, action.pitch(), 0.0001F);
|
||||
assertEquals(SoundSource.NEUTRAL, action.category());
|
||||
assertEquals(new ResourceLocation("tiedup", "play_sound"), action.type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_missingOptionals_usesDefaults() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("sound", new JsonPrimitive("minecraft:ui.button.click"));
|
||||
|
||||
DataResult<PlaySoundAction> parsed = PlaySoundAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(parsed.result().isPresent(), "parse with only sound must succeed");
|
||||
|
||||
PlaySoundAction action = parsed.result().get();
|
||||
assertEquals(1.0F, action.volume(), 0.0001F);
|
||||
assertEquals(1.0F, action.pitch(), 0.0001F);
|
||||
assertEquals(SoundSource.NEUTRAL, action.category());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_missingRequiredSound_fails() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("volume", new JsonPrimitive(1.0F));
|
||||
|
||||
DataResult<PlaySoundAction> parsed = PlaySoundAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertFalse(parsed.result().isPresent(), "parse without sound must fail");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode omits default values — that's Mojang's standard
|
||||
* {@code optionalFieldOf(name, default)} semantics. So a record whose
|
||||
* volume / pitch / category all match their defaults encodes down to
|
||||
* only the {@code sound} field.
|
||||
*/
|
||||
@Test
|
||||
void encode_defaultValues_areOmitted() {
|
||||
PlaySoundAction action = new PlaySoundAction(
|
||||
new ResourceLocation("minecraft", "entity.player.levelup"),
|
||||
1.0F, // default volume
|
||||
1.0F, // default pitch
|
||||
SoundSource.NEUTRAL // default category
|
||||
);
|
||||
|
||||
DataResult<JsonElement> encoded = PlaySoundAction.CODEC.encodeStart(JsonOps.INSTANCE, action);
|
||||
assertTrue(encoded.result().isPresent());
|
||||
|
||||
JsonObject json = encoded.result().get().getAsJsonObject();
|
||||
assertEquals("minecraft:entity.player.levelup", json.get("sound").getAsString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-default values must appear in the encoded output.
|
||||
*/
|
||||
@Test
|
||||
void encode_nonDefaultValues_arePresent() {
|
||||
PlaySoundAction action = new PlaySoundAction(
|
||||
new ResourceLocation("minecraft", "entity.player.levelup"),
|
||||
0.8F, // non-default
|
||||
1.2F, // non-default
|
||||
SoundSource.PLAYERS // non-default
|
||||
);
|
||||
|
||||
DataResult<JsonElement> encoded = PlaySoundAction.CODEC.encodeStart(JsonOps.INSTANCE, action);
|
||||
assertTrue(encoded.result().isPresent());
|
||||
|
||||
JsonObject json = encoded.result().get().getAsJsonObject();
|
||||
assertEquals("minecraft:entity.player.levelup", json.get("sound").getAsString());
|
||||
assertNotNull(json.get("volume"));
|
||||
assertEquals(0.8F, json.get("volume").getAsFloat(), 0.0001F);
|
||||
assertNotNull(json.get("pitch"));
|
||||
assertEquals(1.2F, json.get("pitch").getAsFloat(), 0.0001F);
|
||||
}
|
||||
|
||||
@Test
|
||||
void type_isStable() {
|
||||
assertEquals(new ResourceLocation("tiedup", "play_sound"), PlaySoundAction.ID);
|
||||
PlaySoundAction action = new PlaySoundAction(
|
||||
new ResourceLocation("minecraft", "ambient.cave"),
|
||||
1.0F, 1.0F, SoundSource.AMBIENT
|
||||
);
|
||||
assertEquals(PlaySoundAction.ID, action.type());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
|
||||
*/
|
||||
|
||||
package com.tiedup.remake.rig.anim.action.impl;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
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.Test;
|
||||
|
||||
/**
|
||||
* Parse / default tests for {@link SpawnParticleAction}.
|
||||
*/
|
||||
class SpawnParticleActionTest {
|
||||
|
||||
@Test
|
||||
void parse_fullObject_populates() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("particle", new JsonPrimitive("minecraft:smoke"));
|
||||
json.add("at", new JsonPrimitive("Head"));
|
||||
json.add("count", new JsonPrimitive(5));
|
||||
json.add("speed", new JsonPrimitive(0.05F));
|
||||
json.add("offset_x", new JsonPrimitive(0.1F));
|
||||
json.add("offset_y", new JsonPrimitive(1.2F));
|
||||
json.add("offset_z", new JsonPrimitive(-0.3F));
|
||||
|
||||
DataResult<SpawnParticleAction> parsed = SpawnParticleAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(parsed.result().isPresent(), "parse must succeed : " + parsed.error());
|
||||
|
||||
SpawnParticleAction action = parsed.result().get();
|
||||
assertEquals(new ResourceLocation("minecraft", "smoke"), action.particle());
|
||||
assertEquals("Head", action.joint());
|
||||
assertEquals(5, action.count());
|
||||
assertEquals(0.05F, action.speed(), 0.0001F);
|
||||
assertEquals(0.1F, action.offsetX(), 0.0001F);
|
||||
assertEquals(1.2F, action.offsetY(), 0.0001F);
|
||||
assertEquals(-0.3F, action.offsetZ(), 0.0001F);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_minimal_usesDefaults() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("particle", new JsonPrimitive("minecraft:flame"));
|
||||
|
||||
DataResult<SpawnParticleAction> parsed = SpawnParticleAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertTrue(parsed.result().isPresent(), "parse must succeed with defaults");
|
||||
|
||||
SpawnParticleAction action = parsed.result().get();
|
||||
assertEquals("", action.joint()); // "" == default to entity origin
|
||||
assertEquals(1, action.count());
|
||||
assertEquals(0.0F, action.speed(), 0.0001F);
|
||||
assertEquals(0.0F, action.offsetX(), 0.0001F);
|
||||
assertEquals(0.0F, action.offsetY(), 0.0001F);
|
||||
assertEquals(0.0F, action.offsetZ(), 0.0001F);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_missingParticle_fails() {
|
||||
JsonObject json = new JsonObject();
|
||||
json.add("count", new JsonPrimitive(3));
|
||||
|
||||
DataResult<SpawnParticleAction> parsed = SpawnParticleAction.CODEC.parse(JsonOps.INSTANCE, json);
|
||||
assertFalse(parsed.result().isPresent(), "parse without particle must fail");
|
||||
}
|
||||
|
||||
@Test
|
||||
void type_isStable() {
|
||||
assertEquals(new ResourceLocation("tiedup", "spawn_particle"), SpawnParticleAction.ID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
* © 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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
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.action.AnimationAction;
|
||||
import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents;
|
||||
import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents.PeriodSerializedEvent;
|
||||
import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents.SimpleSerializedEvent;
|
||||
import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents.TimeSerializedEvent;
|
||||
import com.tiedup.remake.rig.anim.action.impl.PlaySoundAction;
|
||||
import com.tiedup.remake.rig.anim.property.AnimationEvent.SimpleEvent;
|
||||
import com.tiedup.remake.rig.anim.property.AnimationProperty.StaticAnimationProperty;
|
||||
|
||||
/**
|
||||
* Tests the Phase 3 D2 wiring : {@code on_begin}, {@code on_end} and
|
||||
* {@code tick_events} properties must be accessible via
|
||||
* {@link AnimationProperty#getSerializableProperty(String)} and must parse
|
||||
* real datapack-shaped JSON blocks into runtime
|
||||
* {@link com.tiedup.remake.rig.anim.property.AnimationEvent} instances.
|
||||
*
|
||||
* <p>No MC bootstrap — Mojang Codec stack + Gson only.
|
||||
*/
|
||||
class AnimationEventCodecTest {
|
||||
|
||||
@BeforeAll
|
||||
static void forceClinit() {
|
||||
// Reference the fields explicitly so JUnit test ordering doesn't leave
|
||||
// getSerializableProperty("on_begin") throwing IllegalStateException.
|
||||
assertNotNull(StaticAnimationProperty.ON_BEGIN_EVENTS);
|
||||
assertNotNull(StaticAnimationProperty.ON_END_EVENTS);
|
||||
assertNotNull(StaticAnimationProperty.TICK_EVENTS);
|
||||
// Also ensure the action registry clinit has run so dispatchCodec is
|
||||
// populated.
|
||||
assertNotNull(AnimationAction.CODEC);
|
||||
assertNotNull(PlaySoundAction.ID);
|
||||
}
|
||||
|
||||
// ===== Registration =====
|
||||
|
||||
@Test
|
||||
void onBegin_registeredUnderName() {
|
||||
AnimationProperty<?> prop = AnimationProperty.getSerializableProperty("on_begin");
|
||||
assertNotNull(prop, "on_begin must be exposed via getSerializableProperty");
|
||||
assertEquals(StaticAnimationProperty.ON_BEGIN_EVENTS, prop);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onEnd_registeredUnderName() {
|
||||
AnimationProperty<?> prop = AnimationProperty.getSerializableProperty("on_end");
|
||||
assertNotNull(prop);
|
||||
assertEquals(StaticAnimationProperty.ON_END_EVENTS, prop);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tickEvents_registeredUnderName() {
|
||||
AnimationProperty<?> prop = AnimationProperty.getSerializableProperty("tick_events");
|
||||
assertNotNull(prop);
|
||||
assertEquals(StaticAnimationProperty.TICK_EVENTS, prop);
|
||||
}
|
||||
|
||||
// ===== on_begin / on_end parse =====
|
||||
|
||||
/**
|
||||
* Bare action list shape :
|
||||
* <pre>[{"type":"tiedup:play_sound", "sound":"minecraft:ui.button.click"}]</pre>
|
||||
* — the SUGAR_CODEC maps this to a single {@link SimpleSerializedEvent}
|
||||
* with {@link AnimationEvent.Side#BOTH} as default.
|
||||
*/
|
||||
@Test
|
||||
void onBegin_bareActionList_parses() {
|
||||
String jsonText = """
|
||||
[
|
||||
[{"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"}]
|
||||
]
|
||||
""";
|
||||
JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
|
||||
|
||||
List<SimpleEvent<?>> events = StaticAnimationProperty.ON_BEGIN_EVENTS.parseFrom(arr);
|
||||
assertEquals(1, events.size());
|
||||
assertNotNull(events.get(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Full object shape :
|
||||
* <pre>{"actions":[...], "side":"SERVER"}</pre>
|
||||
*/
|
||||
@Test
|
||||
void onBegin_fullObjectWithSide_parses() {
|
||||
String jsonText = """
|
||||
[
|
||||
{
|
||||
"actions": [{"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"}],
|
||||
"side": "SERVER"
|
||||
}
|
||||
]
|
||||
""";
|
||||
JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
|
||||
|
||||
List<SimpleEvent<?>> events = StaticAnimationProperty.ON_BEGIN_EVENTS.parseFrom(arr);
|
||||
assertEquals(1, events.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onEnd_multipleEntries_parses() {
|
||||
String jsonText = """
|
||||
[
|
||||
[{"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"}],
|
||||
[{"type":"tiedup:apply_effect","effect":"minecraft:slowness","duration_ticks":40}]
|
||||
]
|
||||
""";
|
||||
JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
|
||||
|
||||
List<SimpleEvent<?>> events = StaticAnimationProperty.ON_END_EVENTS.parseFrom(arr);
|
||||
assertEquals(2, events.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* The parseFrom on a purely lossy encoding path also tests that the
|
||||
* on_begin codec does not reject multiple actions inside one entry.
|
||||
*/
|
||||
@Test
|
||||
void onBegin_multipleActionsPerEntry_parses() {
|
||||
String jsonText = """
|
||||
[
|
||||
[
|
||||
{"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"},
|
||||
{"type":"tiedup:apply_effect","effect":"minecraft:slowness","duration_ticks":40,"amplifier":1}
|
||||
]
|
||||
]
|
||||
""";
|
||||
JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
|
||||
|
||||
List<SimpleEvent<?>> events = StaticAnimationProperty.ON_BEGIN_EVENTS.parseFrom(arr);
|
||||
assertEquals(1, events.size());
|
||||
}
|
||||
|
||||
// ===== tick_events parse =====
|
||||
|
||||
@Test
|
||||
void tickEvents_timeEvent_parses() {
|
||||
String jsonText = """
|
||||
[
|
||||
{
|
||||
"frame": 0.15,
|
||||
"actions": [{"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"}]
|
||||
}
|
||||
]
|
||||
""";
|
||||
JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
|
||||
|
||||
List<AnimationEvent<?, ?>> events = StaticAnimationProperty.TICK_EVENTS.parseFrom(arr);
|
||||
assertEquals(1, events.size());
|
||||
assertTrue(
|
||||
events.get(0) instanceof AnimationEvent.InTimeEvent,
|
||||
"time-shape JSON must yield InTimeEvent, got : " + events.get(0).getClass().getSimpleName()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tickEvents_periodEvent_parses() {
|
||||
String jsonText = """
|
||||
[
|
||||
{
|
||||
"start": 0.1,
|
||||
"end": 0.8,
|
||||
"actions": [{"type":"tiedup:spawn_particle","particle":"minecraft:smoke","count":3}]
|
||||
}
|
||||
]
|
||||
""";
|
||||
JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
|
||||
|
||||
List<AnimationEvent<?, ?>> events = StaticAnimationProperty.TICK_EVENTS.parseFrom(arr);
|
||||
assertEquals(1, events.size());
|
||||
assertTrue(
|
||||
events.get(0) instanceof AnimationEvent.InPeriodEvent,
|
||||
"period-shape JSON must yield InPeriodEvent, got : " + events.get(0).getClass().getSimpleName()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tickEvents_mixed_parses() {
|
||||
String jsonText = """
|
||||
[
|
||||
{
|
||||
"frame": 0.2,
|
||||
"actions": [{"type":"tiedup:play_sound","sound":"minecraft:ui.button.click"}]
|
||||
},
|
||||
{
|
||||
"start": 0.5,
|
||||
"end": 1.0,
|
||||
"actions": [{"type":"tiedup:spawn_particle","particle":"minecraft:flame"}]
|
||||
}
|
||||
]
|
||||
""";
|
||||
JsonArray arr = JsonParser.parseString(jsonText).getAsJsonArray();
|
||||
|
||||
List<AnimationEvent<?, ?>> events = StaticAnimationProperty.TICK_EVENTS.parseFrom(arr);
|
||||
assertEquals(2, events.size());
|
||||
assertTrue(events.get(0) instanceof AnimationEvent.InTimeEvent);
|
||||
assertTrue(events.get(1) instanceof AnimationEvent.InPeriodEvent);
|
||||
}
|
||||
|
||||
// ===== Serialized event direct codec tests =====
|
||||
|
||||
@Test
|
||||
void simpleSerializedEvent_codec_roundtrip() {
|
||||
SimpleSerializedEvent ev = new SimpleSerializedEvent(
|
||||
List.of(new PlaySoundAction(
|
||||
new net.minecraft.resources.ResourceLocation("minecraft", "ui.button.click"),
|
||||
1.0F, 1.0F, net.minecraft.sounds.SoundSource.NEUTRAL
|
||||
)),
|
||||
AnimationEvent.Side.SERVER
|
||||
);
|
||||
|
||||
DataResult<com.google.gson.JsonElement> enc =
|
||||
SimpleSerializedEvent.CODEC.encodeStart(JsonOps.INSTANCE, ev);
|
||||
assertTrue(enc.result().isPresent(), "encode must succeed : " + enc.error());
|
||||
|
||||
DataResult<SimpleSerializedEvent> dec =
|
||||
SimpleSerializedEvent.CODEC.parse(JsonOps.INSTANCE, enc.result().get());
|
||||
assertTrue(dec.result().isPresent(), "decode must succeed : " + dec.error());
|
||||
|
||||
SimpleSerializedEvent back = dec.result().get();
|
||||
assertEquals(1, back.actions().size());
|
||||
assertEquals(AnimationEvent.Side.SERVER, back.side());
|
||||
}
|
||||
|
||||
@Test
|
||||
void timeSerializedEvent_codec_roundtrip() {
|
||||
TimeSerializedEvent ev = new TimeSerializedEvent(
|
||||
0.42F,
|
||||
List.of(new PlaySoundAction(
|
||||
new net.minecraft.resources.ResourceLocation("minecraft", "ambient.cave"),
|
||||
0.5F, 1.0F, net.minecraft.sounds.SoundSource.AMBIENT
|
||||
)),
|
||||
AnimationEvent.Side.CLIENT
|
||||
);
|
||||
|
||||
DataResult<com.google.gson.JsonElement> enc =
|
||||
TimeSerializedEvent.CODEC.encodeStart(JsonOps.INSTANCE, ev);
|
||||
assertTrue(enc.result().isPresent());
|
||||
|
||||
DataResult<TimeSerializedEvent> dec =
|
||||
TimeSerializedEvent.CODEC.parse(JsonOps.INSTANCE, enc.result().get());
|
||||
assertTrue(dec.result().isPresent());
|
||||
assertEquals(0.42F, dec.result().get().frame(), 0.0001F);
|
||||
}
|
||||
|
||||
@Test
|
||||
void periodSerializedEvent_codec_roundtrip() {
|
||||
PeriodSerializedEvent ev = new PeriodSerializedEvent(
|
||||
0.1F, 0.9F,
|
||||
List.of(new PlaySoundAction(
|
||||
new net.minecraft.resources.ResourceLocation("minecraft", "ambient.cave"),
|
||||
1.0F, 1.0F, net.minecraft.sounds.SoundSource.AMBIENT
|
||||
)),
|
||||
AnimationEvent.Side.BOTH
|
||||
);
|
||||
|
||||
DataResult<com.google.gson.JsonElement> enc =
|
||||
PeriodSerializedEvent.CODEC.encodeStart(JsonOps.INSTANCE, ev);
|
||||
assertTrue(enc.result().isPresent());
|
||||
|
||||
DataResult<PeriodSerializedEvent> dec =
|
||||
PeriodSerializedEvent.CODEC.parse(JsonOps.INSTANCE, enc.result().get());
|
||||
assertTrue(dec.result().isPresent());
|
||||
assertEquals(0.1F, dec.result().get().start(), 0.0001F);
|
||||
assertEquals(0.9F, dec.result().get().end(), 0.0001F);
|
||||
}
|
||||
|
||||
// ===== toRuntime conversion =====
|
||||
|
||||
@Test
|
||||
void simpleSerializedEvent_toRuntime_producesNonNullSimpleEvent() {
|
||||
SimpleSerializedEvent ev = new SimpleSerializedEvent(
|
||||
List.of(), AnimationEvent.Side.BOTH
|
||||
);
|
||||
SimpleEvent<?> runtime = ev.toRuntime();
|
||||
assertNotNull(runtime);
|
||||
}
|
||||
|
||||
@Test
|
||||
void timeSerializedEvent_toRuntime_producesInTimeEvent() {
|
||||
TimeSerializedEvent ev = new TimeSerializedEvent(
|
||||
0.5F, List.of(), AnimationEvent.Side.BOTH
|
||||
);
|
||||
AnimationEvent<?, ?> runtime = ev.toRuntime();
|
||||
assertTrue(runtime instanceof AnimationEvent.InTimeEvent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void periodSerializedEvent_toRuntime_producesInPeriodEvent() {
|
||||
PeriodSerializedEvent ev = new PeriodSerializedEvent(
|
||||
0.0F, 1.0F, List.of(), AnimationEvent.Side.BOTH
|
||||
);
|
||||
AnimationEvent<?, ?> runtime = ev.toRuntime();
|
||||
assertTrue(runtime instanceof AnimationEvent.InPeriodEvent);
|
||||
}
|
||||
|
||||
// ===== Edge cases =====
|
||||
|
||||
/**
|
||||
* Empty list must parse to an empty runtime list (not a parse error).
|
||||
*/
|
||||
@Test
|
||||
void onBegin_emptyList_parsesToEmpty() {
|
||||
JsonArray empty = new JsonArray();
|
||||
List<SimpleEvent<?>> events = StaticAnimationProperty.ON_BEGIN_EVENTS.parseFrom(empty);
|
||||
assertNotNull(events);
|
||||
assertTrue(events.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tickEvents_emptyList_parsesToEmpty() {
|
||||
JsonArray empty = new JsonArray();
|
||||
List<AnimationEvent<?, ?>> events = StaticAnimationProperty.TICK_EVENTS.parseFrom(empty);
|
||||
assertNotNull(events);
|
||||
assertTrue(events.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void beginEndEventsCodec_listOf_invokable() {
|
||||
// Guard that the top-level codec is wired correctly — paranoia check.
|
||||
assertNotNull(DataDrivenAnimationEvents.BEGIN_END_EVENTS_CODEC);
|
||||
assertNotNull(DataDrivenAnimationEvents.TICK_EVENTS_CODEC);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user