Files
selimaj-devandclaude 9d0fe6fb6b Build for Minecraft 26.1, 26.2 and 26.3
Adds the three drops as Stonecutter versions (26.1's jar also accepts
its hotfixes). loom-back-compat already picks the plain Loom for 26.x;
the testmod's remap configurations are only created where there's
something to remap, and the foojay resolver downloads the Java 25
toolchain these versions need.

- 26.3 renamed PoseStack.mulPose(Quaternionfc) to rotate.
- The testmod follows Fabric API's renames: ClientCommands (26.1),
  getClientLevel (26.1) and getConnection (26.2) for waiting on chunks.
- 26.2 replaced Options.hideGui with the HUD's toggle, so the gametest
  toggles it through a helper.

The screenshot gametest passes on 26.1, 26.2 and 26.3, and the bent
limbs match 1.21.11's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 18:40:11 +02:00

7.1 KiB

emotes

A player emote library for Saturn Client, with limb bending.

Layout

  • core/: Minecraft-independent animation model (Bone, BoneTransform, Pose, Animation, AnimationPlayer), keyframes and easings, the Emotecraft JSON loader and the EmoteRegistry. Plain Java 21 plus Jackson, with JUnit tests. Published as org.saturnclient:emotes-core.
  • src/main/: the Fabric mod that puts poses on player models. It's built once per Minecraft version with Stonecutter and published as org.saturnclient:emotes-fabric:<version>+<mc>, with core and Jackson 2.17.0 (the version Saturn Client ships) bundled inside the jar.
  • src/testmod/: a dev-only tester mod that is never published. It loads Saturn's emotes into the library's registry and keeps its own hand-written test animations in a separate registry. /emotes play <id> plays a registered emote, /emotes test <name> a test animation, and there's /emotes stop and /emotes info. Press F5 to see yourself. It also has a Fabric client gametest that creates a world and screenshots each animation from the front and side, into versions/<mc>/build/run/clientGameTest/screenshots/.
  • tools/capture/: a Python tool that measures an emote from a store page's 3D preview and writes it as Emotecraft JSON. See its README.
  • tools/emotecraft/convert.py: converts Emotecraft's binary .emotecraft files to Emotecraft JSON (standard-library Python).

Supported versions: 1.21.4 to 1.21.11, 26.1 (and its hotfixes), 26.2 and 26.3. Each version's Fabric API version is set in stonecutter.properties.toml. The code uses Mojang mappings, which 26.1+ ships as its own names, so loom-back-compat applies the remapping Loom for 1.21.x and the plain one for 26.x. 26.x builds need Java 25; Gradle downloads it when it isn't installed.

Using it

// At startup: load emotes (Emotecraft / player-animation-lib JSON) into the registry.
try (InputStream in = MyMod.class.getResourceAsStream("/assets/mymod/player_animations/facepalm.json")) {
    Emotes.registry().loadEmotecraft("facepalm", in);
}

Emotes.play(player.getUUID(), "facepalm");   // false if there's no such emote
Emotes.play(player.getUUID(), animation);    // or any Animation, e.g. written in code
Emotes.stop(player.getUUID());
Emotes.playing(player.getUUID());            // Optional<AnimationPlayer>

Emotes work for any player you can see, run on game ticks, and a non-looping one stops by itself. Emotes.registry().all() lists the emotes with their name, description and author, for an emote wheel.

An Animation returns a Pose for a time in ticks: a BoneTransform (offset in model pixels, pitch/yaw/roll in radians, bend and bend axis) for any of ROOT (the whole player), HEAD, BODY, RIGHT_ARM, LEFT_ARM, RIGHT_LEG and LEFT_LEG. Bones it doesn't set keep their vanilla pose. KeyframeAnimation builds one from keyframes and Easings.

EmotecraftLoader follows player-animation-lib's reader: absolute part pivots become offsets, body (and torso before format version 3) is the whole player in blocks, angles are degrees unless "degrees": false, turn adds whole turns, and loops jump back to returnTick after endTick. Emotecraft's two-segment bends become our curved ones: a limb's bend changes sign, the torso's doesn't, and a bend on the whole player bends the torso. Scale channels, easingArg and blending with the vanilla pose aren't supported yet (issue #4). Binary .emotecraft files can be converted to JSON first with tools/emotecraft/convert.py.

How it hooks in

  1. LivingEntityRendererMixin samples the entity's current pose into its render state (players only).
  2. HumanoidModelMixin applies that pose at the end of HumanoidModel.setupAnim. PlayerModel and the armor layers both go through this method, so armor follows the pose.

Both hook points exist unchanged in every supported version. Version-specific code goes behind Stonecutter comments, like the vertex accessors in BentCubeRenderer:

//? if >=1.21.9 {
return new float[] { vertex.x(), vertex.y(), vertex.z(), vertex.u(), vertex.v() };
//?} else
/*return new float[] { vertex.pos().x(), vertex.pos().y(), vertex.pos().z(), vertex.u(), vertex.v() };*/

Bending

Each limb bends along a single axis (BoneTransform.bend, bendAxis). It works like the cloak renderer: build a spine, put a ring of shared vertices along it, and draw the faces between the rings.

  • LimbBend (core) runs the spine along a cubic Bézier (Bezier). The control points are the rigid "upper half, joint, lower half" shape, and sharpness goes from a round curve (0) to a tight joint (1). The spine is cut into one ring per texture row (12 per limb), spaced evenly by arc length, so the limb keeps its length. Each ring moves its cross-section rigidly, so neighbouring pieces share vertices and the mesh stays closed.
  • QuadSplitter (core) cuts each vanilla face into strips at the rings and splits its UVs to match. The texture is unchanged apart from the cuts.
  • ModelPartMixin swaps in BentCubeRenderer for any part with a bend. That renderer deforms vanilla's own cubes, so slim arms, sleeves, pants and armor all work. Overlays and armor use the limb's spine, not their own inflated box, so they stay attached.

Axis 0 folds towards the front (-Z) and PI/2 towards +X, in the limb's own space. Knees fold back, so they use axis PI.

The torso is anchored at the hips instead of its pivot (LimbBend.anchoredAtEnd), so the legs stay attached and the chest leans. The head and arms are siblings of the body in the vanilla model, so PoseApplier carries them: their pivots follow the bent torso, and they turn with its ring there (LimbBend.angleAt). An arm carried by a forward lean swings back like a diver's, so emotes that want hanging arms tilt them forward by about the lean (see the twerk test animation).

Held items still attach to the unbent arm, and the cape doesn't follow the torso yet.

Commands

./gradlew :core:test                      # core unit tests
./gradlew :1.21.11:runTestmodClient       # launch the game with the tester (run dir: ./run)
./gradlew :1.21.11:runClientGameTest      # screenshot each test animation, then quit
./gradlew :1.21.4:compileTestmodJava      # quick compile check for one version
./gradlew buildAll                        # every version's jar → build/libs/<version>/
GITEA_TOKEN=... ./gradlew publish         # publish core + every version to Gitea

The active version is set in stonecutter.gradle.kts (stonecutter active "..."), and it's the version whose code is uncommented in src/. Switch versions with the Stonecutter IntelliJ plugin or the Set active project to <v> Gradle task. Run Reset active project before committing, so the tree is committed with vcsVersion (1.21.11) active.

Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17, which builds 26.x as well (with a Java 25 toolchain for those versions only).

Publishing

Artifacts go to the saturnclientmc organization's Maven registry: https://git.selimaj.dev/api/packages/saturnclientmc/maven. Use a token with package:write, set as GITEA_TOKEN or -PgiteaToken=....