Scaffold the emote library with a Stonecutter Fabric mod and tester
- core/: Minecraft-independent animation model (Bone, BoneTransform with bend fields, Pose, Animation, AnimationPlayer) with JUnit tests. - src/main: Fabric mod built for 1.21.4-1.21.11 with Stonecutter on Mojang mappings. Samples each player's pose into its render state and applies it at the end of HumanoidModel.setupAnim (armor follows too). - src/testmod: dev-only tester with /emotes play|stop|info and a mixin audit on startup. - Publishing to the saturnclientmc Gitea Maven registry as org.saturnclient:emotes-core and emotes-fabric:<v>+<mc>. Loom is pinned to 1.17 because 1.18 needs a Java 25 JVM. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package org.saturnclient.emotes.core;
|
||||
|
||||
/** Something that produces a {@link Pose} for any point in time. */
|
||||
public interface Animation {
|
||||
/**
|
||||
* @param tick time since the animation started, in game ticks (20 per second). Fractional
|
||||
* values come from the render partial tick.
|
||||
*/
|
||||
Pose sample(float tick);
|
||||
|
||||
/** Length in ticks. Looping animations repeat after this, others end. */
|
||||
float length();
|
||||
|
||||
default boolean loops() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.saturnclient.emotes.core;
|
||||
|
||||
/** Plays one {@link Animation} from a fixed start tick. */
|
||||
public final class AnimationPlayer {
|
||||
private final Animation animation;
|
||||
private final long startTick;
|
||||
|
||||
public AnimationPlayer(Animation animation, long startTick) {
|
||||
this.animation = animation;
|
||||
this.startTick = startTick;
|
||||
}
|
||||
|
||||
public Animation animation() {
|
||||
return animation;
|
||||
}
|
||||
|
||||
/** Elapsed animation time, wrapped for looping animations. */
|
||||
public float time(long tick, float partialTick) {
|
||||
float elapsed = Math.max(0, tick - startTick + partialTick);
|
||||
float length = animation.length();
|
||||
if (animation.loops() && length > 0) {
|
||||
return elapsed % length;
|
||||
}
|
||||
return Math.min(elapsed, length);
|
||||
}
|
||||
|
||||
public boolean isFinished(long tick) {
|
||||
return !animation.loops() && tick - startTick >= animation.length();
|
||||
}
|
||||
|
||||
public Pose sample(long tick, float partialTick) {
|
||||
if (isFinished(tick)) {
|
||||
return Pose.EMPTY;
|
||||
}
|
||||
return animation.sample(time(tick, partialTick));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.saturnclient.emotes.core;
|
||||
|
||||
/** The parts of the player model an animation can move. */
|
||||
public enum Bone {
|
||||
HEAD,
|
||||
BODY,
|
||||
RIGHT_ARM,
|
||||
LEFT_ARM,
|
||||
RIGHT_LEG,
|
||||
LEFT_LEG
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.saturnclient.emotes.core;
|
||||
|
||||
/**
|
||||
* How one bone is posed. Offsets are in model pixels (1/16 block) relative to the bone's default
|
||||
* pivot, rotations are in radians and replace the vanilla rotation.
|
||||
*
|
||||
* @param bend how far the lower half of the limb (forearm, shin, lower torso) folds, in radians
|
||||
* @param bendAxis the direction the limb folds in, as an angle around the limb's long axis
|
||||
*/
|
||||
public record BoneTransform(
|
||||
float x, float y, float z,
|
||||
float pitch, float yaw, float roll,
|
||||
float bend, float bendAxis) {
|
||||
|
||||
public static final BoneTransform IDENTITY = new BoneTransform(0, 0, 0, 0, 0, 0, 0, 0);
|
||||
|
||||
public static BoneTransform rotation(float pitch, float yaw, float roll) {
|
||||
return new BoneTransform(0, 0, 0, pitch, yaw, roll, 0, 0);
|
||||
}
|
||||
|
||||
public BoneTransform withBend(float bend, float bendAxis) {
|
||||
return new BoneTransform(x, y, z, pitch, yaw, roll, bend, bendAxis);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.saturnclient.emotes.core;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** An immutable snapshot of bone transforms. Bones without a transform keep their vanilla pose. */
|
||||
public final class Pose {
|
||||
public static final Pose EMPTY = new Pose(new EnumMap<>(Bone.class));
|
||||
|
||||
private final EnumMap<Bone, BoneTransform> bones;
|
||||
|
||||
private Pose(EnumMap<Bone, BoneTransform> bones) {
|
||||
this.bones = bones;
|
||||
}
|
||||
|
||||
public Optional<BoneTransform> get(Bone bone) {
|
||||
return Optional.ofNullable(bones.get(bone));
|
||||
}
|
||||
|
||||
public Map<Bone, BoneTransform> bones() {
|
||||
return Map.copyOf(bones);
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return bones.isEmpty();
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private final EnumMap<Bone, BoneTransform> bones = new EnumMap<>(Bone.class);
|
||||
|
||||
private Builder() {
|
||||
}
|
||||
|
||||
public Builder set(Bone bone, BoneTransform transform) {
|
||||
bones.put(bone, transform);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Pose build() {
|
||||
return bones.isEmpty() ? EMPTY : new Pose(new EnumMap<>(bones));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.saturnclient.emotes.core;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
class AnimationPlayerTest {
|
||||
/** Rotates the head by one radian per tick. */
|
||||
private static Animation headSpin(float length, boolean loops) {
|
||||
return new Animation() {
|
||||
@Override
|
||||
public Pose sample(float tick) {
|
||||
return Pose.builder().set(Bone.HEAD, BoneTransform.rotation(0, tick, 0)).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float length() {
|
||||
return length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean loops() {
|
||||
return loops;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static float headYaw(Pose pose) {
|
||||
return pose.get(Bone.HEAD).orElseThrow().yaw();
|
||||
}
|
||||
|
||||
@Test
|
||||
void samplesRelativeToStartTick() {
|
||||
AnimationPlayer player = new AnimationPlayer(headSpin(20, false), 100);
|
||||
assertEquals(5.5f, headYaw(player.sample(105, 0.5f)), 1e-6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void finishesAfterLength() {
|
||||
AnimationPlayer player = new AnimationPlayer(headSpin(20, false), 100);
|
||||
assertFalse(player.isFinished(119));
|
||||
assertTrue(player.isFinished(120));
|
||||
assertTrue(player.sample(120, 0).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loopingAnimationsWrap() {
|
||||
AnimationPlayer player = new AnimationPlayer(headSpin(20, true), 0);
|
||||
assertFalse(player.isFinished(1_000));
|
||||
assertEquals(5f, headYaw(player.sample(45, 0)), 1e-6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void timeBeforeStartClampsToZero() {
|
||||
AnimationPlayer player = new AnimationPlayer(headSpin(20, false), 100);
|
||||
assertEquals(0f, player.time(90, 0), 1e-6);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user