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:
notevil
2026-04-24 21:20:31 +02:00
parent a7a1c774f7
commit 86d35c4b5d
14 changed files with 1821 additions and 5 deletions

View File

@@ -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 &laquo;unknown
* type&raquo; 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
);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
);
}

View File

@@ -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
));
}
}

View File

@@ -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
}
}

View File

@@ -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
);
}
}

View File

@@ -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 &laquo;root joint position = entity position&raquo; —
* 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 &laquo;simple&raquo; 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;
}
}

View File

@@ -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 &laquo;time&raquo;
* event ({@code {"frame":0.15, "actions":[...]}}) or a
* &laquo;period&raquo; 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 &laquo;action list&raquo;
* ({@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.

View File

@@ -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("?")
);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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()
);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}