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:
2026-09-26 19:05:06 +02:00
co-authored by claude
parent dda96c4acf
commit 8641fc0afe
31 changed files with 1303 additions and 22 deletions
+38
View File
@@ -0,0 +1,38 @@
plugins {
`java-library`
`maven-publish`
}
group = "org.saturnclient"
version = rootProject.property("mod.version") as String
repositories {
mavenCentral()
}
dependencies {
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
java {
// Kept at 21 so it runs on every supported Minecraft version.
toolchain.languageVersion = JavaLanguageVersion.of(21)
withSourcesJar()
}
tasks.test {
useJUnitPlatform()
}
publishing {
publications {
create<MavenPublication>("maven") {
artifactId = "emotes-core"
from(components["java"])
}
}
}
apply(from = rootProject.file("gradle/gitea.gradle.kts"))
@@ -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);
}
}