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
+9
View File
@@ -0,0 +1,9 @@
#
# https://help.github.com/articles/dealing-with-line-endings/
#
# Linux start script should use lf
/gradlew text eol=lf
# These are Windows script files and should use crlf
*.bat text eol=crlf
+34 -20
View File
@@ -1,26 +1,40 @@
# ---> Java
# Compiled class file
*.class
# gradle
# Log file
*.log
.gradle/
build/
out/
classes/
# BlueJ files
*.ctxt
# eclipse
# Mobile Tools for Java (J2ME)
.mtj.tmp/
*.launch
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# idea
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
.idea/
*.iml
*.ipr
*.iws
# vscode
.settings/
.vscode/
bin/
.classpath
.project
# macos
*.DS_Store
# fabric
run/
# java
hs_err_*.log
replay_*.log
*.hprof
*.jfr
+57 -2
View File
@@ -1,3 +1,58 @@
# emote
# emotes
A powerful emote library for Saturn Client, with bending
A player emote library for Saturn Client, with limb bending.
## Layout
- `core/`: Minecraft-independent animation model (`Bone`, `BoneTransform`, `Pose`, `Animation`,
`AnimationPlayer`). Plain Java 21 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](https://stonecutter.kikugie.dev/) and published as
`org.saturnclient:emotes-fabric:<version>+<mc>`, with `core` bundled inside the jar.
- `src/testmod/`: a dev-only tester mod that is never published. It adds `/emotes play <name>`,
`/emotes stop` and `/emotes info`. Press F5 to see yourself.
Supported versions: 1.21.4 to 1.21.11, the same range as Saturn Client. Each version's Fabric API
version is set in `stonecutter.properties.toml`. The code uses Mojang mappings.
## 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:
```java
//? if >=1.21.9 {
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
//?} else
/*import net.minecraft.client.renderer.entity.state.PlayerRenderState;*/
```
Bending (`BoneTransform.bend` / `bendAxis`) is part of the model but isn't rendered yet.
## Commands
```sh
./gradlew :core:test # core unit tests
./gradlew :1.21.11:runTestmodClient # launch the game with the tester (run dir: ./run)
./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 until the
toolchain moves. Minecraft 26.x will need that upgrade.
## 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=...`.
+115
View File
@@ -0,0 +1,115 @@
// Build script for each Minecraft version node (versions/<mc>/). The root project itself
// is configured by stonecutter.gradle.kts.
plugins {
// Applies the right Loom variant for the node's Minecraft version.
id("dev.kikugie.loom-back-compat")
`maven-publish`
}
// DO NOT set group = ...! (Stonecutter/loom-back-compat reads mod.group.)
val modVersion = property("mod.version") as String
version = "$modVersion+${sc.current.version}"
base.archivesName = "emotes-fabric"
val requiredJava: JavaVersion = when {
sc.current.parsed >= "26.1" -> JavaVersion.VERSION_25
else -> JavaVersion.VERSION_21
}
val testmod: SourceSet = sourceSets.create("testmod") {
compileClasspath += sourceSets.main.get().compileClasspath + sourceSets.main.get().output
runtimeClasspath += sourceSets.main.get().runtimeClasspath + sourceSets.main.get().output
}
loom {
createRemapConfigurations(testmod)
mods {
register("saturn_emotes") { sourceSet(sourceSets.main.get()) }
register("saturn_emotes_testmod") { sourceSet(testmod) }
}
decompilerOptions.named("vineflower") {
options.put("mark-corresponding-synthetics", "1") // Names lambdas, useful for mixins
}
runs {
// ./gradlew :<mc>:runTestmodClient launches the game with the tester mod loaded.
register("testmodClient") {
client()
name = "Testmod Client"
source(testmod)
}
}
runConfigs.all {
preferGradleTask = true
generateRunConfig = true
runDirectory = rootProject.file("run") // Shared by every version
jvmArguments.add("-Dmixin.debug.export=true")
}
}
dependencies {
fun fapi(vararg modules: String) {
for (it in modules) modImplementation(fabricApi.module(it, sc.properties["deps.fabric_api"]))
}
minecraft("com.mojang:minecraft:${sc.current.version}")
loomx.applyMojangMappings()
modImplementation("net.fabricmc:fabric-loader:${property("deps.fabric_loader")}")
fapi("fabric-api-base", "fabric-lifecycle-events-v1")
// The core is Minecraft-independent; ship it inside the mod jar.
api(project(":core"))
include(project(":core"))
"modTestmodImplementation"(fabricApi.module("fabric-command-api-v2", sc.properties["deps.fabric_api"]))
}
java {
withSourcesJar()
targetCompatibility = requiredJava
sourceCompatibility = requiredJava
toolchain.languageVersion = JavaLanguageVersion.of(requiredJava.majorVersion)
}
tasks {
val props = mapOf(
"id" to sc.properties["mod.id"],
"name" to sc.properties["mod.name"],
"version" to version.toString(),
"minecraft" to sc.properties["mod.mc_compat"],
"loader" to Regex("\\d+\\.\\d+").find(sc.properties.get<String>("deps.fabric_loader"))!!.value,
"java" to "JAVA_${requiredJava.majorVersion}",
)
withType<ProcessResources>().configureEach {
inputs.properties(props)
filesMatching(listOf("fabric.mod.json", "*.mixins.json")) { expand(props) }
}
withType<Jar>().configureEach {
from(rootProject.file("LICENSE")) { rename { "${it}_saturn_emotes" } }
}
register<Copy>("buildAndCollect") {
group = "build"
description = "Builds the mod jar and copies it to build/libs/<mod version>/ in the root project"
from(loomx.modJar.flatMap { it.archiveFile })
into(rootProject.layout.buildDirectory.dir("libs/$modVersion"))
}
}
publishing {
publications {
create<MavenPublication>("maven") {
groupId = property("mod.group") as String
artifactId = "emotes-fabric"
from(components["java"])
}
}
}
apply(from = rootProject.file("gradle/gitea.gradle.kts"))
+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);
}
}
+5
View File
@@ -0,0 +1,5 @@
# Gradle options only; mod and dependency versions live in stonecutter.properties.toml.
org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
+17
View File
@@ -0,0 +1,17 @@
// Shared publishing target: the saturnclientmc org's Maven registry on Gitea.
// Publish with a token that has package:write: GITEA_TOKEN=... ./gradlew publish
configure<PublishingExtension> {
repositories {
maven {
name = "Gitea"
url = uri("https://git.selimaj.dev/api/packages/saturnclientmc/maven")
credentials(HttpHeaderCredentials::class) {
name = "Authorization"
value = "token ${findProperty("giteaToken") ?: System.getenv("GITEA_TOKEN") ?: ""}"
}
authentication {
create<HttpHeaderAuthentication>("header")
}
}
}
}
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+112
View File
@@ -0,0 +1,112 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+28
View File
@@ -0,0 +1,28 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
maven("https://maven.fabricmc.net/")
maven("https://maven.kikugie.dev/releases") { name = "KikuGie Releases" }
maven("https://maven.kikugie.dev/snapshots") { name = "KikuGie Snapshots" }
}
}
plugins {
id("dev.kikugie.stonecutter") version "0.9.8"
// Picks the right Loom variant per Minecraft version (remapping for 1.21.x, plain for 26.1+).
id("dev.kikugie.loom-back-compat") version "0.4.2"
}
stonecutter {
create(rootProject) {
// Same range as Saturn Client. Per-version dependencies live in stonecutter.properties.toml.
versions("1.21.4", "1.21.5", "1.21.6", "1.21.7", "1.21.8", "1.21.9", "1.21.10", "1.21.11")
vcsVersion = "1.21.11"
}
}
rootProject.name = "emotes"
// Minecraft-independent animation code, shared by every version node.
include("core")
@@ -0,0 +1,56 @@
package org.saturnclient.emotes.fabric;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.saturnclient.emotes.core.Animation;
import org.saturnclient.emotes.core.AnimationPlayer;
import org.saturnclient.emotes.core.Pose;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
/** Client-side entry point: which player is playing which animation. */
public final class Emotes implements ClientModInitializer {
public static final String MOD_ID = "saturn_emotes";
public static final String VERSION = /*$ mod_version*/ "0.1.0";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private static final Map<UUID, AnimationPlayer> PLAYING = new ConcurrentHashMap<>();
private static long tick;
@Override
public void onInitializeClient() {
ClientTickEvents.END_CLIENT_TICK.register(client -> {
tick++;
PLAYING.values().removeIf(player -> player.isFinished(tick));
});
}
/** Starts {@code animation} on the player, replacing whatever they were playing. */
public static void play(UUID player, Animation animation) {
PLAYING.put(player, new AnimationPlayer(animation, tick));
}
public static void stop(UUID player) {
PLAYING.remove(player);
}
public static void stopAll() {
PLAYING.clear();
}
public static Optional<AnimationPlayer> playing(UUID player) {
return Optional.ofNullable(PLAYING.get(player));
}
/** The pose to render for this player this frame, or {@link Pose#EMPTY}. */
public static Pose sample(UUID player, float partialTick) {
AnimationPlayer animation = PLAYING.get(player);
return animation == null ? Pose.EMPTY : animation.sample(tick, partialTick);
}
}
@@ -0,0 +1,45 @@
package org.saturnclient.emotes.fabric;
import org.saturnclient.emotes.core.Bone;
import org.saturnclient.emotes.core.BoneTransform;
import org.saturnclient.emotes.core.Pose;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.model.geom.ModelPart;
/**
* Writes a {@link Pose} onto a humanoid model after vanilla has posed it. Overlay parts (hat, sleeves,
* jacket, pants) are children of the base parts, so they follow automatically.
*/
public final class PoseApplier {
private PoseApplier() {
}
public static void apply(HumanoidModel<?> model, Pose pose) {
for (var entry : pose.bones().entrySet()) {
apply(part(model, entry.getKey()), entry.getValue());
}
}
private static ModelPart part(HumanoidModel<?> model, Bone bone) {
return switch (bone) {
case HEAD -> model.head;
case BODY -> model.body;
case RIGHT_ARM -> model.rightArm;
case LEFT_ARM -> model.leftArm;
case RIGHT_LEG -> model.rightLeg;
case LEFT_LEG -> model.leftLeg;
};
}
private static void apply(ModelPart part, BoneTransform transform) {
// The model's pose was reset to its defaults before setupAnim, so offsets are relative to those.
part.x += transform.x();
part.y += transform.y();
part.z += transform.z();
part.xRot = transform.pitch();
part.yRot = transform.yaw();
part.zRot = transform.roll();
// TODO: bend (transform.bend(), transform.bendAxis()) needs a custom mesh.
}
}
@@ -0,0 +1,10 @@
package org.saturnclient.emotes.fabric.access;
import org.saturnclient.emotes.core.Pose;
/** Mixed into render states so the model knows the pose of the entity it's drawing. */
public interface EmotePoseHolder {
Pose saturnEmotes$getPose();
void saturnEmotes$setPose(Pose pose);
}
@@ -0,0 +1,29 @@
package org.saturnclient.emotes.fabric.mixin;
import org.saturnclient.emotes.core.Pose;
import org.saturnclient.emotes.fabric.PoseApplier;
import org.saturnclient.emotes.fabric.access.EmotePoseHolder;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.renderer.entity.state.HumanoidRenderState;
/**
* PlayerModel.setupAnim ends by calling this, and armor layers call it with the same state,
* so the player and their armor both get the pose.
*/
@Mixin(HumanoidModel.class)
public class HumanoidModelMixin {
@Inject(
method = "setupAnim(Lnet/minecraft/client/renderer/entity/state/HumanoidRenderState;)V",
at = @At("TAIL"))
private void saturnEmotes$applyPose(HumanoidRenderState state, CallbackInfo ci) {
Pose pose = ((EmotePoseHolder) state).saturnEmotes$getPose();
if (!pose.isEmpty()) {
PoseApplier.apply((HumanoidModel<?>) (Object) this, pose);
}
}
}
@@ -0,0 +1,24 @@
package org.saturnclient.emotes.fabric.mixin;
import org.saturnclient.emotes.core.Pose;
import org.saturnclient.emotes.fabric.access.EmotePoseHolder;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import net.minecraft.client.renderer.entity.state.LivingEntityRenderState;
@Mixin(LivingEntityRenderState.class)
public class LivingEntityRenderStateMixin implements EmotePoseHolder {
@Unique
private Pose saturnEmotes$pose = Pose.EMPTY;
@Override
public Pose saturnEmotes$getPose() {
return saturnEmotes$pose;
}
@Override
public void saturnEmotes$setPose(Pose pose) {
saturnEmotes$pose = pose;
}
}
@@ -0,0 +1,27 @@
package org.saturnclient.emotes.fabric.mixin;
import org.saturnclient.emotes.core.Pose;
import org.saturnclient.emotes.fabric.Emotes;
import org.saturnclient.emotes.fabric.access.EmotePoseHolder;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import net.minecraft.client.renderer.entity.LivingEntityRenderer;
import net.minecraft.client.renderer.entity.state.LivingEntityRenderState;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
@Mixin(LivingEntityRenderer.class)
public class LivingEntityRendererMixin {
@Inject(
method = "extractRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;F)V",
at = @At("TAIL"))
private void saturnEmotes$extractPose(LivingEntity entity, LivingEntityRenderState state, float partialTick,
CallbackInfo ci) {
// Render states are reused between entities, so always overwrite.
Pose pose = entity instanceof Player ? Emotes.sample(entity.getUUID(), partialTick) : Pose.EMPTY;
((EmotePoseHolder) state).saturnEmotes$setPose(pose);
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"id": "${id}",
"version": "${version}",
"name": "${name}",
"description": "Player emote library for Saturn Client, with limb bending.",
"authors": ["Saturn Client"],
"contact": {
"sources": "https://git.selimaj.dev/saturnclientmc/emotes"
},
"license": "Apache-2.0",
"environment": "client",
"entrypoints": {
"client": ["org.saturnclient.emotes.fabric.Emotes"]
},
"mixins": ["saturn_emotes.mixins.json"],
"depends": {
"fabricloader": ">=${loader}",
"minecraft": "${minecraft}",
"fabric-lifecycle-events-v1": "*"
}
}
@@ -0,0 +1,13 @@
{
"required": true,
"package": "org.saturnclient.emotes.fabric.mixin",
"compatibilityLevel": "${java}",
"client": [
"HumanoidModelMixin",
"LivingEntityRenderStateMixin",
"LivingEntityRendererMixin"
],
"injectors": {
"defaultRequire": 1
}
}
@@ -0,0 +1,63 @@
package org.saturnclient.emotes.testmod;
import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument;
import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal;
import java.util.TreeSet;
import org.saturnclient.emotes.core.Animation;
import org.saturnclient.emotes.fabric.Emotes;
import org.spongepowered.asm.mixin.MixinEnvironment;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
import net.minecraft.network.chat.Component;
/**
* Dev-only tester. In game: {@code /emotes play <name>}, {@code /emotes stop}, {@code /emotes info}.
* Switch to third person (F5) to see yourself.
*/
public final class EmotesTestmod implements ClientModInitializer {
private static final String MINECRAFT = /*$ minecraft*/ "1.21.11";
@Override
public void onInitializeClient() {
// Applies every mixin now, so a broken target crashes at startup instead of in a world.
MixinEnvironment.getCurrentEnvironment().audit();
Emotes.LOGGER.info("Mixin audit passed on Minecraft {}", MINECRAFT);
ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(
literal("emotes")
.then(literal("play").then(argument("name", StringArgumentType.word())
.suggests((ctx, builder) -> {
TestAnimations.ALL.keySet().forEach(builder::suggest);
return builder.buildFuture();
})
.executes(EmotesTestmod::play)))
.then(literal("stop").executes(ctx -> {
Emotes.stop(ctx.getSource().getPlayer().getUUID());
return 1;
}))
.then(literal("info").executes(ctx -> {
ctx.getSource().sendFeedback(Component.literal("Saturn Emotes " + Emotes.VERSION
+ " on Minecraft " + MINECRAFT + ". Animations: "
+ String.join(", ", new TreeSet<>(TestAnimations.ALL.keySet()))));
return 1;
}))));
}
private static int play(CommandContext<FabricClientCommandSource> ctx) {
String name = StringArgumentType.getString(ctx, "name");
Animation animation = TestAnimations.ALL.get(name);
if (animation == null) {
ctx.getSource().sendError(Component.literal("Unknown animation: " + name));
return 0;
}
Emotes.play(ctx.getSource().getPlayer().getUUID(), animation);
return 1;
}
}
@@ -0,0 +1,66 @@
package org.saturnclient.emotes.testmod;
import java.util.Map;
import java.util.function.Function;
import org.saturnclient.emotes.core.Animation;
import org.saturnclient.emotes.core.Bone;
import org.saturnclient.emotes.core.BoneTransform;
import org.saturnclient.emotes.core.Pose;
/** Hand-written animations for checking that poses reach the model on every version. */
final class TestAnimations {
private static final float HALF_PI = (float) (Math.PI / 2);
static final Map<String, Animation> ALL = Map.of(
// Right arm raised, waving side to side. Tests rotation.
"wave", of(40, true, t -> Pose.builder()
.set(Bone.RIGHT_ARM, BoneTransform.rotation((float) Math.PI, 0,
0.4f * (float) Math.sin(t * 0.4f)))
.build()),
// Arms straight out, then back after 3 seconds. Tests non-looping end.
"tpose", of(60, false, t -> Pose.builder()
.set(Bone.RIGHT_ARM, BoneTransform.rotation(0, 0, HALF_PI))
.set(Bone.LEFT_ARM, BoneTransform.rotation(0, 0, -HALF_PI))
.build()),
// Body bobbing down and up. Tests offsets.
"bob", of(20, true, t -> {
float drop = 2f * (1 - (float) Math.cos(t / 20f * 2 * Math.PI));
return Pose.builder()
.set(Bone.HEAD, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
.set(Bone.BODY, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
.set(Bone.RIGHT_ARM, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
.set(Bone.LEFT_ARM, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
.build();
}),
// Arms forward with elbows folding in and out. Bend isn't rendered yet.
"bend", of(40, true, t -> {
float bend = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI));
return Pose.builder()
.set(Bone.RIGHT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0))
.set(Bone.LEFT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0))
.build();
}));
private TestAnimations() {
}
private static Animation of(float length, boolean loops, Function<Float, Pose> sampler) {
return new Animation() {
@Override
public Pose sample(float tick) {
return sampler.apply(tick);
}
@Override
public float length() {
return length;
}
@Override
public boolean loops() {
return loops;
}
};
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"id": "saturn_emotes_testmod",
"version": "${version}",
"name": "Saturn Emotes Tester",
"description": "Dev-only commands for trying Saturn Emotes in game.",
"license": "Apache-2.0",
"environment": "client",
"entrypoints": {
"client": ["org.saturnclient.emotes.testmod.EmotesTestmod"]
},
"depends": {
"saturn_emotes": "*",
"fabric-command-api-v2": "*"
}
}
+23
View File
@@ -0,0 +1,23 @@
plugins {
id("dev.kikugie.stonecutter")
}
stonecutter active "1.21.11"
// See https://stonecutter.kikugie.dev/wiki/config/params
stonecutter parameters {
swaps["mod_version"] = "\"${property("mod.version")}\";"
swaps["minecraft"] = "\"${node.metadata.version}\";"
replacements {
string(current.parsed >= "1.21.11") {
replace("ResourceLocation", "Identifier")
}
}
}
// Builds and collects every version's jar into build/libs/<mod version>/.
tasks.register("buildAll") {
group = "build"
dependsOn(stonecutter.tasks.named("buildAndCollect"))
}
+40
View File
@@ -0,0 +1,40 @@
# See https://stonecutter.kikugie.dev/wiki/config/properties#centralized-properties
mod.id = "saturn_emotes"
mod.name = "Saturn Emotes"
mod.group = "org.saturnclient"
mod.version = "0.1.0"
deps.fabric_loader = "0.19.5"
loomx.loom_version = "1.17.21"
["1.21.4"]
mod.mc_compat = "1.21.4"
deps.fabric_api = "0.119.4+1.21.4"
["1.21.5"]
mod.mc_compat = "1.21.5"
deps.fabric_api = "0.128.2+1.21.5"
["1.21.6"]
mod.mc_compat = "1.21.6"
deps.fabric_api = "0.128.2+1.21.6"
["1.21.7"]
mod.mc_compat = "1.21.7"
deps.fabric_api = "0.129.0+1.21.7"
["1.21.8"]
mod.mc_compat = "1.21.8"
deps.fabric_api = "0.136.1+1.21.8"
["1.21.9"]
mod.mc_compat = "1.21.9"
deps.fabric_api = "0.134.1+1.21.9"
["1.21.10"]
mod.mc_compat = "1.21.10"
deps.fabric_api = "0.138.4+1.21.10"
["1.21.11"]
mod.mc_compat = "1.21.11"
deps.fabric_api = "0.141.6+1.21.11"