Load Emotecraft emotes into a registry and add a whole-player bone #5

Merged
selimaj-dev merged 1 commits from emote-loader into master 2026-09-26 18:54:44 +00:00
33 changed files with 17596 additions and 36 deletions
+34 -3
View File
@@ -5,11 +5,15 @@ 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`.
`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](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>`,
`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/`.
@@ -17,6 +21,33 @@ A player emote library for Saturn Client, with limb bending.
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.
## Using it
```java
// 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 `Easing`s.
`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`. Scale
channels, `easingArg` and blending with the vanilla pose aren't supported yet (issue #4).
## How it hooks in
1. `LivingEntityRendererMixin` samples the entity's current pose into its render state (players only).
+5 -1
View File
@@ -70,9 +70,13 @@ dependencies {
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.
// The core is Minecraft-independent; ship it inside the mod jar, with the Jackson it reads emotes
// with (the version Saturn Client ships too, so the game only loads one).
api(project(":core"))
include(project(":core"))
for (module in listOf("jackson-databind", "jackson-core", "jackson-annotations")) {
include("com.fasterxml.jackson.core:$module:2.17.0")
}
for (module in listOf("fabric-command-api-v2", "fabric-client-gametest-api-v1")) {
"modTestmodImplementation"(fabricApi.module(module, sc.properties["deps.fabric_api"]))
+3
View File
@@ -11,6 +11,9 @@ repositories {
}
dependencies {
// Same version Saturn Client ships, so only one copy ends up in the game.
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.0")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
@@ -14,4 +14,12 @@ public interface Animation {
default boolean loops() {
return false;
}
/**
* Where a looping animation starts again after reaching {@link #length()}, in ticks. The part
* before it plays once, like an intro.
*/
default float loopStart() {
return 0;
}
}
@@ -18,10 +18,18 @@ public final class AnimationPlayer {
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;
if (!animation.loops()) {
return Math.min(elapsed, length);
}
return Math.min(elapsed, length);
if (elapsed < length) {
return elapsed;
}
float loopStart = animation.loopStart();
float loopLength = length - loopStart;
if (loopLength <= 0) {
return length;
}
return loopStart + (elapsed - length) % loopLength;
}
public boolean isFinished(long tick) {
@@ -2,7 +2,14 @@ package org.saturnclient.emotes.core;
/** The parts of the player model an animation can move. */
public enum Bone {
/**
* The whole player. Offsets are in model pixels (Y down, like the other bones), and rotations turn
* the whole model around its middle, 12.8 pixels below the neck (0.7 blocks above the feet). Used
* for flips, spins and jumps.
*/
ROOT,
HEAD,
/** The torso. It bends from the hips; see {@code LimbBend#anchoredAtEnd}. */
BODY,
RIGHT_ARM,
LEFT_ARM,
@@ -0,0 +1,9 @@
package org.saturnclient.emotes.core;
/**
* An animation with the details a player sees, like in an emote wheel.
*
* @param id identifies the emote, for example in network messages ("facepalm")
*/
public record Emote(String id, String name, String description, String author, Animation animation) {
}
@@ -0,0 +1,47 @@
package org.saturnclient.emotes.core;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.saturnclient.emotes.core.format.EmotecraftLoader;
/** Emotes by ID, in the order they were added. Safe to use from any thread. */
public final class EmoteRegistry {
private final Map<String, Emote> emotes = new LinkedHashMap<>();
/** Adds the emote, replacing any with the same ID. */
public synchronized Emote register(Emote emote) {
emotes.put(emote.id(), emote);
return emote;
}
/** Reads an emote in Emotecraft's JSON format and registers it under {@code id}. */
public Emote loadEmotecraft(String id, InputStream json) throws IOException {
return register(EmotecraftLoader.load(id, json));
}
public synchronized Optional<Emote> get(String id) {
return Optional.ofNullable(emotes.get(id));
}
public synchronized List<Emote> all() {
return List.copyOf(emotes.values());
}
public synchronized List<String> ids() {
return new ArrayList<>(emotes.keySet());
}
public synchronized boolean remove(String id) {
return emotes.remove(id) != null;
}
public synchronized void clear() {
emotes.clear();
}
}
@@ -0,0 +1,116 @@
package org.saturnclient.emotes.core.format;
import java.util.Locale;
/**
* Easing curves between keyframes (see easings.net). Each maps progress through a segment, from 0
* to 1, to how far the value has moved.
*/
public enum Easing {
LINEAR,
/** Holds the earlier keyframe's value until the next one. */
CONSTANT,
INSINE, OUTSINE, INOUTSINE,
INQUAD, OUTQUAD, INOUTQUAD,
INCUBIC, OUTCUBIC, INOUTCUBIC,
INQUART, OUTQUART, INOUTQUART,
INQUINT, OUTQUINT, INOUTQUINT,
INEXPO, OUTEXPO, INOUTEXPO,
INCIRC, OUTCIRC, INOUTCIRC,
INBACK, OUTBACK, INOUTBACK,
INELASTIC, OUTELASTIC, INOUTELASTIC,
INBOUNCE, OUTBOUNCE, INOUTBOUNCE;
private static final double BACK = 1.70158;
private static final double BACK_INOUT = BACK * 1.525;
/**
* Reads an Emotecraft easing name. Case and underscores don't matter, and the optional "EASE"
* prefix is dropped ("EASEINOUTQUAD" and "inOutQuad" both work). Unknown names are linear.
*/
public static Easing fromName(String name) {
if (name == null) {
return LINEAR;
}
String key = name.toUpperCase(Locale.ROOT).replace("_", "");
if (key.startsWith("EASE")) {
key = key.substring(4);
}
try {
return valueOf(key);
} catch (IllegalArgumentException e) {
return LINEAR;
}
}
public float apply(float progress) {
double t = Math.max(0, Math.min(1, progress));
return (float) switch (this) {
case LINEAR -> t;
case CONSTANT -> t < 1 ? 0 : 1;
case INSINE -> 1 - Math.cos(t * Math.PI / 2);
case OUTSINE -> Math.sin(t * Math.PI / 2);
case INOUTSINE -> -(Math.cos(Math.PI * t) - 1) / 2;
case INQUAD -> in(t, 2);
case OUTQUAD -> out(t, 2);
case INOUTQUAD -> inOut(t, 2);
case INCUBIC -> in(t, 3);
case OUTCUBIC -> out(t, 3);
case INOUTCUBIC -> inOut(t, 3);
case INQUART -> in(t, 4);
case OUTQUART -> out(t, 4);
case INOUTQUART -> inOut(t, 4);
case INQUINT -> in(t, 5);
case OUTQUINT -> out(t, 5);
case INOUTQUINT -> inOut(t, 5);
case INEXPO -> t == 0 ? 0 : Math.pow(2, 10 * t - 10);
case OUTEXPO -> t == 1 ? 1 : 1 - Math.pow(2, -10 * t);
case INOUTEXPO -> t == 0 || t == 1 ? t
: t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2;
case INCIRC -> 1 - Math.sqrt(1 - t * t);
case OUTCIRC -> Math.sqrt(1 - (t - 1) * (t - 1));
case INOUTCIRC -> t < 0.5 ? (1 - Math.sqrt(1 - 4 * t * t)) / 2
: (Math.sqrt(1 - Math.pow(-2 * t + 2, 2)) + 1) / 2;
case INBACK -> (BACK + 1) * t * t * t - BACK * t * t;
case OUTBACK -> 1 + (BACK + 1) * Math.pow(t - 1, 3) + BACK * Math.pow(t - 1, 2);
case INOUTBACK -> t < 0.5
? Math.pow(2 * t, 2) * ((BACK_INOUT + 1) * 2 * t - BACK_INOUT) / 2
: (Math.pow(2 * t - 2, 2) * ((BACK_INOUT + 1) * (t * 2 - 2) + BACK_INOUT) + 2) / 2;
case INELASTIC -> t == 0 || t == 1 ? t
: -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * (2 * Math.PI / 3));
case OUTELASTIC -> t == 0 || t == 1 ? t
: Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI / 3)) + 1;
case INOUTELASTIC -> t == 0 || t == 1 ? t
: t < 0.5 ? -(Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * (2 * Math.PI / 4.5))) / 2
: Math.pow(2, -20 * t + 10) * Math.sin((20 * t - 11.125) * (2 * Math.PI / 4.5)) / 2 + 1;
case INBOUNCE -> 1 - bounce(1 - t);
case OUTBOUNCE -> bounce(t);
case INOUTBOUNCE -> t < 0.5 ? (1 - bounce(1 - 2 * t)) / 2 : (1 + bounce(2 * t - 1)) / 2;
};
}
private static double in(double t, int power) {
return Math.pow(t, power);
}
private static double out(double t, int power) {
return 1 - Math.pow(1 - t, power);
}
private static double inOut(double t, int power) {
return t < 0.5 ? Math.pow(2, power - 1) * Math.pow(t, power) : 1 - Math.pow(-2 * t + 2, power) / 2;
}
private static double bounce(double t) {
double n = 7.5625;
double d = 2.75;
if (t < 1 / d) {
return n * t * t;
} else if (t < 2 / d) {
return n * (t -= 1.5 / d) * t + 0.75;
} else if (t < 2.5 / d) {
return n * (t -= 2.25 / d) * t + 0.9375;
}
return n * (t -= 2.625 / d) * t + 0.984375;
}
}
@@ -0,0 +1,172 @@
package org.saturnclient.emotes.core.format;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import org.saturnclient.emotes.core.Bone;
import org.saturnclient.emotes.core.Emote;
import org.saturnclient.emotes.core.format.KeyframeAnimation.Channel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Reads emotes in Emotecraft's JSON format, following player-animation-lib's reader:
*
* <pre>
* { "version": 1, "name": ..., "description": ..., "author": ...,
* "emote": { "isLoop": ..., "returnTick": ..., "endTick": ..., "stopTick": ..., "degrees": ...,
* "moves": [ { "tick": 10, "easing": "INOUTQUAD", "turn": 0,
* "rightArm": { "pitch": -1.9, "bend": -0.9 } }, ... ] } }
* </pre>
*
* <p>Parts are {@code head}, {@code torso}, {@code rightArm}, {@code leftArm}, {@code rightLeg},
* {@code leftLeg} and {@code body} (the whole player, {@link Bone#ROOT}). Before format version 3,
* {@code torso} also means the whole player. Axes are {@code x}, {@code y}, {@code z}, {@code pitch},
* {@code yaw}, {@code roll}, {@code bend} and {@code axis}. Values are converted to this library's
* conventions:
* <ul>
* <li>Part positions are absolute pivots (the right arm rests at x = -5, y = 2), so the rest pivot is
* subtracted to get an offset.</li>
* <li>Whole-player positions are in blocks with Y up, and its rotations are around the world axes
* before Minecraft flips the model, so X, Y, pitch and yaw change sign.</li>
* <li>Emotecraft bends the other way, so {@code bend} changes sign.</li>
* <li>Angles are in degrees unless {@code "degrees": false}.</li>
* <li>{@code turn} adds whole turns to the move's angles, for spins and flips.</li>
* <li>Looping emotes jump back to {@code returnTick} after {@code endTick}, and only loop when they
* have a {@code returnTick}.</li>
* </ul>
*
* <p>Not supported: {@code scaleX}/{@code scaleY}/{@code scaleZ}, {@code easingArg}, and the blend with
* the vanilla pose before the first keyframe and between {@code endTick} and {@code stopTick} (the
* first and last keyframes are held instead).
*/
public final class EmotecraftLoader {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Map<String, Bone> PARTS = Map.of(
"body", Bone.ROOT,
"head", Bone.HEAD,
"torso", Bone.BODY, // Bone.ROOT before version 3
"rightArm", Bone.RIGHT_ARM,
"leftArm", Bone.LEFT_ARM,
"rightLeg", Bone.RIGHT_LEG,
"leftLeg", Bone.LEFT_LEG);
private static final Map<String, Channel> AXES = Map.of(
"x", Channel.X,
"y", Channel.Y,
"z", Channel.Z,
"pitch", Channel.PITCH,
"yaw", Channel.YAW,
"roll", Channel.ROLL,
"bend", Channel.BEND,
"axis", Channel.BEND_AXIS);
/** Emotecraft's rest pivots, {x, y, z} in model pixels. */
private static final Map<Bone, float[]> REST_PIVOTS = Map.of(
Bone.RIGHT_ARM, new float[] { -5, 2, 0 },
Bone.LEFT_ARM, new float[] { 5, 2, 0 },
Bone.RIGHT_LEG, new float[] { -1.9f, 12, 0.1f },
Bone.LEFT_LEG, new float[] { 1.9f, 12, 0.1f });
private static final float FULL_TURN = (float) (2 * Math.PI);
private EmotecraftLoader() {
}
public static Emote load(String id, InputStream json) throws IOException {
return read(id, MAPPER.readTree(json));
}
public static Emote load(String id, String json) throws IOException {
return read(id, MAPPER.readTree(json));
}
private static Emote read(String id, JsonNode root) throws IOException {
JsonNode emote = root.path("emote");
JsonNode moves = emote.path("moves");
if (!moves.isArray()) {
throw new IOException("Emote " + id + " has no \"emote.moves\" array");
}
int version = root.path("version").asInt(1);
boolean degrees = bool(emote.path("degrees"), true);
KeyframeAnimation.Builder animation = KeyframeAnimation.builder()
.easeBefore(bool(emote.path("easeBeforeKeyframe"), false));
float lastTick = 0;
for (JsonNode move : moves) {
float tick = (float) move.path("tick").asDouble();
lastTick = Math.max(lastTick, tick);
Easing easing = Easing.fromName(move.path("easing").asText(null));
float turn = (float) move.path("turn").asDouble(0) * FULL_TURN;
for (Map.Entry<String, Bone> part : PARTS.entrySet()) {
JsonNode axes = move.get(part.getKey());
if (axes == null || !axes.isObject()) {
continue;
}
Bone bone = part.getKey().equals("torso") && version < 3 ? Bone.ROOT : part.getValue();
for (Map.Entry<String, Channel> axis : AXES.entrySet()) {
JsonNode value = axes.get(axis.getKey());
if (value != null && value.isNumber()) {
float converted = convert(bone, axis.getValue(), value.floatValue(), degrees, turn);
animation.keyframe(bone, axis.getValue(), tick, converted, easing);
}
}
}
}
float endTick = (float) emote.path("endTick").asDouble(lastTick);
if (bool(emote.path("isLoop"), false) && emote.has("returnTick")) {
// endTick itself plays before jumping back.
float returnTick = (float) emote.path("returnTick").asDouble();
animation.length(endTick + 1).loop(returnTick <= endTick ? returnTick : 0);
} else {
animation.length((float) emote.path("stopTick").asDouble(endTick));
}
return new Emote(id, text(root.path("name"), id), text(root.path("description"), ""),
text(root.path("author"), ""), animation.build());
}
private static float convert(Bone bone, Channel channel, float value, boolean degrees, float turn) {
return switch (channel) {
case X, Y, Z -> {
if (bone == Bone.ROOT) {
// Blocks to pixels, and into model space, where X and Y point the other way.
yield channel == Channel.Z ? value * 16 : -value * 16;
}
float[] rest = REST_PIVOTS.get(bone);
yield rest == null ? value : value - rest[channel.ordinal()];
}
case PITCH, YAW, ROLL -> {
float radians = angle(value, degrees, turn);
yield bone == Bone.ROOT && channel != Channel.ROLL ? -radians : radians;
}
case BEND -> -angle(value, degrees, turn);
case BEND_AXIS -> angle(value, degrees, turn);
};
}
private static float angle(float value, boolean degrees, float turn) {
return (degrees ? (float) Math.toRadians(value) : value) + turn;
}
/** Emotecraft writes booleans both as JSON booleans and as "true"/"false" strings. */
private static boolean bool(JsonNode node, boolean fallback) {
if (node.isBoolean()) {
return node.booleanValue();
}
if (node.isTextual()) {
return Boolean.parseBoolean(node.textValue());
}
return fallback;
}
private static String text(JsonNode node, String fallback) {
return node.isTextual() ? node.textValue() : fallback;
}
}
@@ -0,0 +1,165 @@
package org.saturnclient.emotes.core.format;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import org.saturnclient.emotes.core.Animation;
import org.saturnclient.emotes.core.Bone;
import org.saturnclient.emotes.core.BoneTransform;
import org.saturnclient.emotes.core.Pose;
/**
* An animation made of keyframes: for each bone, each {@link Channel} has its own list of values at
* given ticks, and values in between follow the segment's {@link Easing}.
*
* <p>A bone with any keyframes gets a full {@link BoneTransform} each frame. Its channels without
* keyframes are 0. Bones without keyframes keep their vanilla pose. Before a channel's first keyframe
* and after its last one, it holds that keyframe's value.
*/
public final class KeyframeAnimation implements Animation {
/** The fields of a {@link BoneTransform}, in the same units. */
public enum Channel {
X, Y, Z, PITCH, YAW, ROLL, BEND, BEND_AXIS
}
public record Keyframe(float tick, float value, Easing easing) {
}
private final Map<Bone, Map<Channel, Keyframe[]>> tracks;
private final float length;
private final boolean loops;
private final float loopStart;
private final boolean easeBefore;
private KeyframeAnimation(Builder builder) {
this.tracks = new EnumMap<>(Bone.class);
builder.keyframes.forEach((bone, channels) -> {
Map<Channel, Keyframe[]> sorted = new EnumMap<>(Channel.class);
channels.forEach((channel, keyframes) -> sorted.put(channel, sortedUnique(keyframes)));
tracks.put(bone, sorted);
});
this.length = builder.length;
this.loops = builder.loops;
this.loopStart = builder.loopStart;
this.easeBefore = builder.easeBefore;
}
public static Builder builder() {
return new Builder();
}
@Override
public float length() {
return length;
}
@Override
public boolean loops() {
return loops;
}
@Override
public float loopStart() {
return loopStart;
}
/** The keyframes of one channel, sorted by tick, or an empty array. */
public Keyframe[] keyframes(Bone bone, Channel channel) {
Map<Channel, Keyframe[]> channels = tracks.get(bone);
Keyframe[] keyframes = channels == null ? null : channels.get(channel);
return keyframes == null ? new Keyframe[0] : keyframes.clone();
}
@Override
public Pose sample(float tick) {
Pose.Builder pose = Pose.builder();
float[] values = new float[Channel.values().length];
for (Map.Entry<Bone, Map<Channel, Keyframe[]>> track : tracks.entrySet()) {
Arrays.fill(values, 0);
for (Map.Entry<Channel, Keyframe[]> channel : track.getValue().entrySet()) {
values[channel.getKey().ordinal()] = valueAt(channel.getValue(), tick);
}
pose.set(track.getKey(), new BoneTransform(values[0], values[1], values[2], values[3], values[4],
values[5], values[6], values[7]));
}
return pose.build();
}
private float valueAt(Keyframe[] keyframes, float tick) {
if (tick <= keyframes[0].tick()) {
return keyframes[0].value();
}
for (int i = 0; i + 1 < keyframes.length; i++) {
Keyframe from = keyframes[i];
Keyframe to = keyframes[i + 1];
if (tick < to.tick()) {
float progress = (tick - from.tick()) / (to.tick() - from.tick());
Easing easing = easeBefore ? to.easing() : from.easing();
return from.value() + (to.value() - from.value()) * easing.apply(progress);
}
}
return keyframes[keyframes.length - 1].value();
}
/** Sorts by tick. Of several keyframes on the same tick, the last one added wins. */
private static Keyframe[] sortedUnique(List<Keyframe> keyframes) {
List<Keyframe> sorted = new ArrayList<>(keyframes);
sorted.sort(Comparator.comparingDouble(Keyframe::tick)); // Stable, so the added order is kept
List<Keyframe> unique = new ArrayList<>(sorted.size());
for (Keyframe keyframe : sorted) {
if (!unique.isEmpty() && unique.get(unique.size() - 1).tick() == keyframe.tick()) {
unique.set(unique.size() - 1, keyframe);
} else {
unique.add(keyframe);
}
}
return unique.toArray(Keyframe[]::new);
}
public static final class Builder {
private final Map<Bone, Map<Channel, List<Keyframe>>> keyframes = new EnumMap<>(Bone.class);
private float length;
private boolean loops;
private float loopStart;
private boolean easeBefore;
private Builder() {
}
public Builder keyframe(Bone bone, Channel channel, float tick, float value, Easing easing) {
keyframes.computeIfAbsent(bone, b -> new EnumMap<>(Channel.class))
.computeIfAbsent(channel, c -> new ArrayList<>())
.add(new Keyframe(tick, value, easing));
return this;
}
/** Length in ticks. A looping animation repeats from {@code loopStart} after this. */
public Builder length(float length) {
this.length = length;
return this;
}
public Builder loop(float loopStart) {
this.loops = true;
this.loopStart = loopStart;
return this;
}
/**
* Whether a segment uses the easing of the keyframe it moves towards. By default it uses the
* easing of the keyframe it starts from, like Emotecraft's original format.
*/
public Builder easeBefore(boolean easeBefore) {
this.easeBefore = easeBefore;
return this;
}
public KeyframeAnimation build() {
return new KeyframeAnimation(this);
}
}
}
@@ -57,4 +57,34 @@ class AnimationPlayerTest {
AnimationPlayer player = new AnimationPlayer(headSpin(20, false), 100);
assertEquals(0f, player.time(90, 0), 1e-6);
}
@Test
void loopsRestartFromLoopStart() {
Animation withIntro = 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 20;
}
@Override
public boolean loops() {
return true;
}
@Override
public float loopStart() {
return 5;
}
};
AnimationPlayer player = new AnimationPlayer(withIntro, 0);
assertEquals(3, player.time(3, 0), 1e-6); // intro plays once
assertEquals(19, player.time(19, 0), 1e-6);
assertEquals(5, player.time(20, 0), 1e-6); // then 5..20 repeats
assertEquals(7, player.time(37, 0), 1e-6);
}
}
@@ -0,0 +1,71 @@
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import org.junit.jupiter.api.Test;
class EmoteRegistryTest {
private static Emote emote(String id) {
return new Emote(id, id, "", "", new Animation() {
@Override
public Pose sample(float tick) {
return Pose.EMPTY;
}
@Override
public float length() {
return 1;
}
});
}
@Test
void findsEmotesById() {
EmoteRegistry registry = new EmoteRegistry();
Emote wave = registry.register(emote("wave"));
assertSame(wave, registry.get("wave").orElseThrow());
assertTrue(registry.get("dab").isEmpty());
}
@Test
void keepsTheOrderEmotesWereAdded() {
EmoteRegistry registry = new EmoteRegistry();
registry.register(emote("b"));
registry.register(emote("a"));
registry.register(emote("c"));
assertEquals(List.of("b", "a", "c"), registry.ids());
}
@Test
void replacesEmotesWithTheSameId() {
EmoteRegistry registry = new EmoteRegistry();
registry.register(emote("wave"));
Emote replacement = registry.register(emote("wave"));
assertEquals(1, registry.all().size());
assertSame(replacement, registry.get("wave").orElseThrow());
}
@Test
void removesEmotes() {
EmoteRegistry registry = new EmoteRegistry();
registry.register(emote("wave"));
assertTrue(registry.remove("wave"));
assertFalse(registry.remove("wave"));
}
@Test
void loadsEmotecraftFiles() throws IOException {
EmoteRegistry registry = new EmoteRegistry();
try (InputStream in = getClass().getResourceAsStream("/emotes/cry.json")) {
registry.loadEmotecraft("cry", in);
}
assertEquals("T_T", registry.get("cry").orElseThrow().description());
}
}
@@ -0,0 +1,44 @@
package org.saturnclient.emotes.core.format;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class EasingTest {
@Test
void everyEasingStartsAtZeroAndEndsAtOne() {
for (Easing easing : Easing.values()) {
assertEquals(0, easing.apply(0), 1e-5, easing.name());
assertEquals(1, easing.apply(1), 1e-5, easing.name());
}
}
@Test
void symmetricEasingsAreHalfwayAtTheMiddle() {
for (Easing easing : new Easing[] { Easing.LINEAR, Easing.INOUTSINE, Easing.INOUTQUAD, Easing.INOUTCUBIC,
Easing.INOUTQUART, Easing.INOUTQUINT, Easing.INOUTEXPO, Easing.INOUTCIRC }) {
assertEquals(0.5, easing.apply(0.5f), 1e-5, easing.name());
}
}
@Test
void inIsSlowAtFirstAndOutIsFast() {
assertEquals(0.25, Easing.INQUAD.apply(0.5f), 1e-6);
assertEquals(0.75, Easing.OUTQUAD.apply(0.5f), 1e-6);
}
@Test
void constantHoldsUntilTheEnd() {
assertEquals(0, Easing.CONSTANT.apply(0.99f), 1e-6);
}
@Test
void readsEmotecraftNames() {
assertEquals(Easing.INOUTQUAD, Easing.fromName("INOUTQUAD"));
assertEquals(Easing.INOUTQUAD, Easing.fromName("EASEINOUTQUAD"));
assertEquals(Easing.OUTSINE, Easing.fromName("easeOutSine"));
assertEquals(Easing.INBOUNCE, Easing.fromName("in_bounce"));
assertEquals(Easing.LINEAR, Easing.fromName("wobbly"));
assertEquals(Easing.LINEAR, Easing.fromName(null));
}
}
@@ -0,0 +1,133 @@
package org.saturnclient.emotes.core.format;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.io.InputStream;
import org.junit.jupiter.api.Test;
import org.saturnclient.emotes.core.Bone;
import org.saturnclient.emotes.core.BoneTransform;
import org.saturnclient.emotes.core.Emote;
/** Loads the emotes Saturn Client ships (copied into test resources). */
class EmotecraftLoaderTest {
private static Emote load(String id) throws IOException {
try (InputStream in = EmotecraftLoaderTest.class.getResourceAsStream("/emotes/" + id + ".json")) {
return EmotecraftLoader.load(id, in);
}
}
private static Emote inline(String moves, String emoteFields) throws IOException {
return EmotecraftLoader.load("test", "{\"emote\": {" + emoteFields + "\"moves\": [" + moves + "]}}");
}
private static BoneTransform bone(Emote emote, Bone bone, float tick) {
return emote.animation().sample(tick).get(bone).orElseThrow();
}
@Test
void loadsEveryShippedEmote() throws IOException {
for (String id : new String[] { "back_flip", "cry", "facepalm", "front_flip", "giga_chad", "tpose" }) {
Emote emote = load(id);
assertEquals(id, emote.id());
assertFalse(emote.animation().sample(emote.animation().length() / 2).isEmpty(), id);
}
}
@Test
void readsDetails() throws IOException {
Emote emote = load("facepalm");
assertEquals("facepalm", emote.name());
assertEquals("just facepalm", emote.description());
assertEquals("dsty", emote.author());
}
@Test
void readsTiming() throws IOException {
// facepalm: plays once until stopTick 61.
Emote facepalm = load("facepalm");
assertFalse(facepalm.animation().loops());
assertEquals(61, facepalm.animation().length(), 1e-6);
// cry: loops back to returnTick 5 after playing endTick 60.
Emote cry = load("cry");
assertTrue(cry.animation().loops());
assertEquals(61, cry.animation().length(), 1e-6);
assertEquals(5, cry.animation().loopStart(), 1e-6);
// back_flip writes isLoop as the string "false".
assertFalse(load("back_flip").animation().loops());
}
@Test
void turnsPivotsIntoOffsets() throws IOException {
// tpose's arms start at their rest pivots (x = ±5, y = 2), which is no offset.
BoneTransform arm = bone(load("tpose"), Bone.RIGHT_ARM, 0);
assertEquals(0, arm.x(), 1e-5);
assertEquals(0, arm.y(), 1e-5);
assertEquals(0, arm.z(), 1e-5);
}
@Test
void flipsTheBendDirection() throws IOException {
// cry bends the elbows by -0.96 in Emotecraft terms: towards the face.
assertEquals(0.96f, bone(load("cry"), Bone.RIGHT_ARM, 30).bend(), 0.01);
}
@Test
void mapsTheWholeBodyToRoot() throws IOException {
// front_flip turns "body" pitch to -2π by tick 15, which is a forward flip: +2π around model X.
Emote flip = load("front_flip");
assertEquals(2 * Math.PI, bone(flip, Bone.ROOT, 15).pitch(), 0.01);
}
@Test
void treatsTheTorsoAsTheWholeBodyBeforeVersion3() throws IOException {
// back_flip has no version (so 1) and flips with "torso".
assertEquals(-2 * Math.PI, bone(load("back_flip"), Bone.ROOT, 70).pitch(), 0.01);
assertTrue(load("back_flip").animation().sample(40).get(Bone.BODY).isEmpty());
String move = "{\"tick\": 0, \"torso\": {\"pitch\": 1}}";
Emote version3 = EmotecraftLoader.load("v3",
"{\"version\": 3, \"emote\": {\"degrees\": false, \"moves\": [" + move + "]}}");
assertEquals(1, bone(version3, Bone.BODY, 0).pitch(), 1e-6);
}
@Test
void convertsRootOffsetsFromBlocks() throws IOException {
Emote emote = inline("{\"tick\": 0, \"body\": {\"x\": 1, \"y\": 0.5, \"z\": 0.25}}", "");
BoneTransform root = bone(emote, Bone.ROOT, 0);
assertEquals(-16, root.x(), 1e-5);
assertEquals(-8, root.y(), 1e-5);
assertEquals(4, root.z(), 1e-5);
}
@Test
void anglesAreInDegreesUnlessSaidOtherwise() throws IOException {
Emote emote = inline("{\"tick\": 0, \"head\": {\"pitch\": 90}}", "");
assertEquals(Math.PI / 2, bone(emote, Bone.HEAD, 0).pitch(), 1e-5);
Emote radians = inline("{\"tick\": 0, \"head\": {\"pitch\": 1.5}}", "\"degrees\": false,");
assertEquals(1.5, bone(radians, Bone.HEAD, 0).pitch(), 1e-5);
}
@Test
void onlyLoopsWithAReturnTick() throws IOException {
assertFalse(inline("{\"tick\": 0, \"head\": {\"yaw\": 1}}", "\"isLoop\": true, \"endTick\": 10,")
.animation().loops());
}
@Test
void addsTurns() throws IOException {
Emote emote = inline("{\"tick\": 0, \"turn\": 1, \"head\": {\"yaw\": 0.5}}", "\"degrees\": false,");
assertEquals(0.5 + 2 * Math.PI, bone(emote, Bone.HEAD, 0).yaw(), 1e-5);
}
@Test
void rejectsFilesWithoutMoves() {
assertThrows(IOException.class, () -> EmotecraftLoader.load("broken", "{\"emote\": {}}"));
}
}
@@ -0,0 +1,80 @@
package org.saturnclient.emotes.core.format;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.saturnclient.emotes.core.Bone;
import org.saturnclient.emotes.core.BoneTransform;
import org.saturnclient.emotes.core.format.KeyframeAnimation.Channel;
class KeyframeAnimationTest {
private static BoneTransform head(KeyframeAnimation animation, float tick) {
return animation.sample(tick).get(Bone.HEAD).orElseThrow();
}
@Test
void interpolatesBetweenKeyframes() {
KeyframeAnimation animation = KeyframeAnimation.builder()
.keyframe(Bone.HEAD, Channel.PITCH, 0, 0, Easing.LINEAR)
.keyframe(Bone.HEAD, Channel.PITCH, 10, 1, Easing.LINEAR)
.length(10).build();
assertEquals(0.25f, head(animation, 2.5f).pitch(), 1e-6);
}
@Test
void segmentsUseTheEasingOfTheKeyframeTheyStartFrom() {
KeyframeAnimation animation = KeyframeAnimation.builder()
.keyframe(Bone.HEAD, Channel.PITCH, 0, 0, Easing.INQUAD)
.keyframe(Bone.HEAD, Channel.PITCH, 10, 1, Easing.LINEAR)
.length(10).build();
assertEquals(0.25f, head(animation, 5).pitch(), 1e-6);
}
@Test
void easeBeforeUsesTheEasingOfTheNextKeyframe() {
KeyframeAnimation animation = KeyframeAnimation.builder()
.keyframe(Bone.HEAD, Channel.PITCH, 0, 0, Easing.INQUAD)
.keyframe(Bone.HEAD, Channel.PITCH, 10, 1, Easing.LINEAR)
.easeBefore(true).length(10).build();
assertEquals(0.5f, head(animation, 5).pitch(), 1e-6);
}
@Test
void holdsTheFirstAndLastValues() {
KeyframeAnimation animation = KeyframeAnimation.builder()
.keyframe(Bone.HEAD, Channel.YAW, 5, 1, Easing.LINEAR)
.keyframe(Bone.HEAD, Channel.YAW, 10, 2, Easing.LINEAR)
.length(20).build();
assertEquals(1, head(animation, 0).yaw(), 1e-6);
assertEquals(2, head(animation, 15).yaw(), 1e-6);
}
@Test
void channelsWithoutKeyframesAreZeroAndBonesWithoutKeyframesAreLeftAlone() {
KeyframeAnimation animation = KeyframeAnimation.builder()
.keyframe(Bone.HEAD, Channel.YAW, 0, 1, Easing.LINEAR)
.length(10).build();
assertEquals(0, head(animation, 0).pitch(), 1e-6);
assertTrue(animation.sample(0).get(Bone.BODY).isEmpty());
}
@Test
void laterKeyframesOnTheSameTickWin() {
KeyframeAnimation animation = KeyframeAnimation.builder()
.keyframe(Bone.HEAD, Channel.ROLL, 5, 1, Easing.LINEAR)
.keyframe(Bone.HEAD, Channel.ROLL, 5, 3, Easing.LINEAR)
.length(10).build();
assertEquals(1, animation.keyframes(Bone.HEAD, Channel.ROLL).length);
assertEquals(3, head(animation, 5).roll(), 1e-6);
}
@Test
void keyframesCanBeAddedOutOfOrder() {
KeyframeAnimation animation = KeyframeAnimation.builder()
.keyframe(Bone.HEAD, Channel.PITCH, 10, 1, Easing.LINEAR)
.keyframe(Bone.HEAD, Channel.PITCH, 0, 0, Easing.LINEAR)
.length(10).build();
assertEquals(0.5f, head(animation, 5).pitch(), 1e-6);
}
}
File diff suppressed because it is too large Load Diff
+305
View File
@@ -0,0 +1,305 @@
{
"version": 1,
"valid": true,
"name": "cry",
"description": "T_T",
"author": "dsty",
"emote": {
"beginTick": 0,
"endTick": 60,
"stopTick": 61,
"isLoop": true,
"returnTick": 5,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.34134695
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.22808763
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.21269174
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.32248768
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.0874397
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.17582592
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.21476103
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"yaw": -6.320644E-4
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.029434478
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.039604172
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.037607383
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.050604735
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"roll": -0.00930274
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0010032838
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"roll": -0.009209918
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"roll": -0.012478679
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"roll": 0.031160546
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.8700758
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.4349194
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.4970688
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.9622451
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.9203311
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.6307226
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -0.2971223
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.8864816
}
}
]
}
}
@@ -0,0 +1,389 @@
{
"version": 1,
"valid": true,
"name": "facepalm",
"description": "just facepalm",
"author": "dsty",
"emote": {
"beginTick": 0,
"endTick": 60,
"stopTick": 61,
"isLoop": false,
"returnTick": 2,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.067995355
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.14453278
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.11871816
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.19018666
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.20064461
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.060454715
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.086299464
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.087619215
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.083825104
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.036273066
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0041144555
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"roll": 0.12942825
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"roll": -0.050149817
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"roll": 0.08701207
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"roll": 0.08676299
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"x": -3.8523226
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"x": -3.8523226
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.0664942
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.074402
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.074402
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.02932
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.02932
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.47555572
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.5093512
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.5093512
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.38563538
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.38563538
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.20147677
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.20521602
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.3274244
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.30787703
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.40478125
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.49199954
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.54730254
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.52019864
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
}
]
}
}
@@ -0,0 +1,697 @@
{
"version": 1,
"valid": true,
"name": "front_flip",
"description": "Parkour!!!!1",
"author": "DKinchik",
"emote": {
"beginTick": 0,
"endTick": 17,
"stopTick": 18,
"isLoop": false,
"returnTick": 1,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.7020477
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.7020477
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"x": -1.9
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"x": -1.9
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 11.169379
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 12.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 12.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"z": -0.39214998
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"z": 0.1
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"z": 0.1
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -1.9707682
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -1.9707682
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"roll": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.3665518
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.3665518
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0793308
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0793308
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"x": 1.9
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"x": 1.9
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 10.861001
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 12.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 12.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.5082285
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.1
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.1
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -1.9850811
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -1.9850811
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"x": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"x": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"y": -0.061333913
}
},
{
"tick": 7,
"easing": "OUTQUAD",
"body": {
"y": 0.48852682
}
},
{
"tick": 15,
"easing": "CONSTANT",
"body": {
"y": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"y": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"z": -0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"pitch": 0.0
}
},
{
"tick": 7,
"easing": "OUTQUAD",
"body": {
"pitch": -2.8362873
}
},
{
"tick": 15,
"easing": "CONSTANT",
"body": {
"pitch": -6.2831855
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"pitch": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"yaw": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"yaw": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"roll": -0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"body": {
"bend": -0.0
}
}
]
}
}
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
{
"version": 1,
"valid": true,
"name": "tpose",
"description": "MENACING",
"author": "stephanieisdumb",
"emote": {
"beginTick": 0,
"endTick": 80,
"stopTick": 81,
"isLoop": true,
"returnTick": 20,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"x": -6.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"x": -6.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"y": 3.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"y": 3.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.0704756
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": 0.059108086
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.61077195
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.008776078
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.6060502
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5707964
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5765934
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5707964
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -1.3806202
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"x": 6.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"x": 6.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"y": 3.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"y": 3.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0045553
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.010133168
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.44322997
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.038214765
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -0.5571526
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5707964
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5692185
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5707964
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -1.3810014
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": 0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": 0.0
}
}
]
}
}
@@ -7,6 +7,8 @@ import java.util.concurrent.ConcurrentHashMap;
import org.saturnclient.emotes.core.Animation;
import org.saturnclient.emotes.core.AnimationPlayer;
import org.saturnclient.emotes.core.Emote;
import org.saturnclient.emotes.core.EmoteRegistry;
import org.saturnclient.emotes.core.Pose;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -20,6 +22,7 @@ public final class Emotes implements ClientModInitializer {
public static final String VERSION = /*$ mod_version*/ "0.1.0";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private static final EmoteRegistry REGISTRY = new EmoteRegistry();
private static final Map<UUID, AnimationPlayer> PLAYING = new ConcurrentHashMap<>();
private static long tick;
@@ -31,11 +34,27 @@ public final class Emotes implements ClientModInitializer {
});
}
/** The emotes {@link #play(UUID, String)} looks up. Load or register emotes into it at startup. */
public static EmoteRegistry registry() {
return REGISTRY;
}
/** 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));
}
/**
* Starts the registered emote {@code id} on the player.
*
* @return false if no emote has that ID, in which case nothing changes
*/
public static boolean play(UUID player, String id) {
Optional<Emote> emote = REGISTRY.get(id);
emote.ifPresent(e -> play(player, e.animation()));
return emote.isPresent();
}
public static void stop(UUID player) {
PLAYING.remove(player);
}
@@ -23,9 +23,17 @@ public final class PoseApplier {
private PoseApplier() {
}
/** Where {@link Bone#ROOT} turns around, in model pixels below the neck (0.7 blocks above the feet). */
private static final float ROOT_PIVOT_Y = 12.8f;
public static void apply(HumanoidModel<?> model, Pose pose) {
pose.get(Bone.ROOT).ifPresent(transform -> applyRoot(model.root(), transform));
LimbBend bodyBend = null;
for (Bone bone : Bone.values()) {
if (bone == Bone.ROOT) {
continue;
}
ModelPart part = part(model, bone);
BoneTransform transform = pose.get(bone).orElse(null);
if (transform != null) {
@@ -49,6 +57,7 @@ public final class PoseApplier {
private static ModelPart part(HumanoidModel<?> model, Bone bone) {
return switch (bone) {
case ROOT -> model.root();
case HEAD -> model.head;
case BODY -> model.body;
case RIGHT_ARM -> model.rightArm;
@@ -68,6 +77,21 @@ public final class PoseApplier {
part.zRot = transform.roll();
}
/**
* The root part turns around its own pivot, which is at the neck. Move it so the model turns around
* its middle instead: a point p ends up at offset + pivot + R (p - pivot).
*/
private static void applyRoot(ModelPart root, BoneTransform transform) {
root.xRot = transform.pitch();
root.yRot = transform.yaw();
root.zRot = transform.roll();
Vector3f pivot = new Vector3f(0, ROOT_PIVOT_Y, 0);
Vector3f turned = rotation(root).transform(new Vector3f(pivot));
root.x += transform.x() + pivot.x - turned.x;
root.y += transform.y() + pivot.y - turned.y;
root.z += transform.z() + pivot.z - turned.z;
}
private static LimbBend bend(Bone bone, BoneTransform transform) {
if (transform == null || transform.bend() == 0) {
return null;
@@ -78,7 +102,7 @@ public final class PoseApplier {
float bend = transform.bend();
float axis = transform.bendAxis();
return switch (bone) {
case HEAD -> null;
case ROOT, HEAD -> null;
case RIGHT_ARM, LEFT_ARM -> new LimbBend(-2, 10, bend, axis, SHARPNESS, 12);
case RIGHT_LEG, LEFT_LEG -> new LimbBend(0, 12, bend, axis, SHARPNESS, 12);
// The hips stay on the legs and the chest leans.
@@ -1,5 +1,6 @@
package org.saturnclient.emotes.testmod;
import org.saturnclient.emotes.core.EmoteRegistry;
import org.saturnclient.emotes.fabric.Emotes;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
@@ -15,19 +16,35 @@ import net.minecraft.client.CameraType;
public final class EmotesGameTest implements FabricClientGameTest {
private static final String MINECRAFT = /*$ minecraft*/ "1.21.11";
/** Animation name, the tick to screenshot it at, and the angles to look from (0 is the front). */
/** What to play, the tick to screenshot it at, and the angles to look from (0 is the front). */
private static final Shot[] SHOTS = {
new Shot("tpose", 10, 0, 90),
new Shot("bend", 20, 0, 90),
new Shot("flap", 15, 0, 90),
new Shot("squat", 20, 0, 90),
new Shot("bow", 20, 0, 90),
Shot.test("tpose", 10, 0, 90),
Shot.test("bend", 20, 0, 90),
Shot.test("flap", 15, 0, 90),
Shot.test("squat", 20, 0, 90),
Shot.test("bow", 20, 0, 90),
// Top and bottom of the bounce.
new Shot("twerk", 16, 0, 90, 150),
new Shot("twerk", 4, 0, 90, 150),
Shot.test("twerk", 16, 0, 90, 150),
Shot.test("twerk", 4, 0, 90, 150),
// Saturn's emotes, loaded from Emotecraft JSON into the library's registry.
Shot.emote("facepalm", 40, 0, 90),
Shot.emote("cry", 30, 0, 90),
Shot.emote("tpose", 30, 0),
Shot.emote("front_flip", 9, 90),
Shot.emote("back_flip", 30, 90),
Shot.emote("back_flip", 50, 90),
Shot.emote("giga_chad", 60, 0, 90),
};
private record Shot(String animation, int tick, float... angles) {
/** A test animation or a registered emote, named apart in screenshots since IDs can repeat. */
private record Shot(EmoteRegistry registry, String prefix, String id, int tick, float... angles) {
static Shot test(String id, int tick, float... angles) {
return new Shot(TestAnimations.REGISTRY, "test-", id, tick, angles);
}
static Shot emote(String id, int tick, float... angles) {
return new Shot(Emotes.registry(), "", id, tick, angles);
}
}
@Override
@@ -42,17 +59,18 @@ public final class EmotesGameTest implements FabricClientGameTest {
for (Shot shot : SHOTS) {
context.runOnClient(client -> Emotes.play(client.player.getUUID(),
TestAnimations.ALL.get(shot.animation())));
shot.registry().get(shot.id()).orElseThrow().animation()));
context.waitTicks(shot.tick());
for (float angle : shot.angles()) {
screenshot(context, shot.animation() + "-t" + shot.tick() + "-" + (int) angle, angle);
screenshot(context, shot.prefix() + shot.id() + "-t" + shot.tick() + "-" + (int) angle, angle);
}
context.runOnClient(client -> Emotes.stop(client.player.getUUID()));
}
// A held item should come out of the fist on the bent forearm.
singleplayer.getServer().runCommand("give @a diamond_sword");
context.runOnClient(client -> Emotes.play(client.player.getUUID(), TestAnimations.ALL.get("hold")));
context.runOnClient(client -> Emotes.play(client.player.getUUID(),
TestAnimations.REGISTRY.get("hold").orElseThrow().animation()));
context.waitTicks(10);
screenshot(context, "hold-0", 0);
screenshot(context, "hold-90", 90);
@@ -3,9 +3,13 @@ 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 java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
import org.saturnclient.emotes.core.Animation;
import org.saturnclient.emotes.core.Emote;
import org.saturnclient.emotes.core.EmoteRegistry;
import org.saturnclient.emotes.fabric.Emotes;
import org.spongepowered.asm.mixin.MixinEnvironment;
@@ -18,46 +22,70 @@ 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}.
* Dev-only tester. In game: {@code /emotes play <id>} plays an emote from the library's registry
* (Saturn's emotes, loaded here), {@code /emotes test <name>} plays one of the testmod's own
* {@link TestAnimations}, and there's {@code /emotes stop} and {@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";
/** Saturn Client's emotes, in Emotecraft's format, from assets/saturn_emotes_testmod/player_animations. */
static final List<String> EMOTECRAFT_EMOTES = List.of("back_flip", "cry", "facepalm", "front_flip",
"giga_chad", "tpose");
@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);
loadEmotes();
ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(
literal("emotes")
.then(literal("play").then(argument("name", StringArgumentType.word())
.then(literal("play").then(argument("id", StringArgumentType.word())
.suggests((ctx, builder) -> {
TestAnimations.ALL.keySet().forEach(builder::suggest);
Emotes.registry().ids().forEach(builder::suggest);
return builder.buildFuture();
})
.executes(EmotesTestmod::play)))
.executes(ctx -> play(ctx, Emotes.registry(), "id"))))
.then(literal("test").then(argument("name", StringArgumentType.word())
.suggests((ctx, builder) -> {
TestAnimations.REGISTRY.ids().forEach(builder::suggest);
return builder.buildFuture();
})
.executes(ctx -> play(ctx, TestAnimations.REGISTRY, "name"))))
.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()))));
+ " on Minecraft " + MINECRAFT + ". Emotes: "
+ String.join(", ", Emotes.registry().ids()) + ". Test animations: "
+ String.join(", ", TestAnimations.REGISTRY.ids())));
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));
private static void loadEmotes() {
for (String id : EMOTECRAFT_EMOTES) {
String path = "/assets/saturn_emotes_testmod/player_animations/" + id + ".json";
try (InputStream in = EmotesTestmod.class.getResourceAsStream(path)) {
Emotes.registry().loadEmotecraft(id, in);
} catch (IOException | NullPointerException e) {
Emotes.LOGGER.error("Couldn't load emote {}", id, e);
}
}
}
private static int play(CommandContext<FabricClientCommandSource> ctx, EmoteRegistry registry, String argument) {
String id = StringArgumentType.getString(ctx, argument);
Optional<Emote> emote = registry.get(id);
if (emote.isEmpty()) {
ctx.getSource().sendError(Component.literal("Unknown: " + id));
return 0;
}
Emotes.play(ctx.getSource().getPlayer().getUUID(), animation);
Emotes.play(ctx.getSource().getPlayer().getUUID(), emote.get().animation());
return 1;
}
}
@@ -1,18 +1,24 @@
package org.saturnclient.emotes.testmod;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Function;
import org.saturnclient.emotes.core.Animation;
import org.saturnclient.emotes.core.Bone;
import org.saturnclient.emotes.core.Emote;
import org.saturnclient.emotes.core.EmoteRegistry;
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. */
/**
* Hand-written animations for checking that poses reach the model on every version. They live in the
* testmod's own registry, separate from the library's {@code Emotes.registry()}.
*/
final class TestAnimations {
private static final float HALF_PI = (float) (Math.PI / 2);
static final Map<String, Animation> ALL = Map.of(
private 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,
@@ -75,6 +81,13 @@ final class TestAnimations {
private TestAnimations() {
}
static final EmoteRegistry REGISTRY = new EmoteRegistry();
static {
new TreeMap<>(ALL).forEach((id, animation) ->
REGISTRY.register(new Emote(id, id, "Test animation", "Saturn Emotes testmod", animation)));
}
/**
* Squatting with knees out and hips pushed back, bouncing the hips about 2.5 times a second. The
* torso tips forward around the hips and bends backwards, so the lower back arches in (not a
@@ -0,0 +1,305 @@
{
"version": 1,
"valid": true,
"name": "cry",
"description": "T_T",
"author": "dsty",
"emote": {
"beginTick": 0,
"endTick": 60,
"stopTick": 61,
"isLoop": true,
"returnTick": 5,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.34134695
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.22808763
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.21269174
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.32248768
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.0874397
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.17582592
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.21476103
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"yaw": -6.320644E-4
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.029434478
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.039604172
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.037607383
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.050604735
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"roll": -0.00930274
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0010032838
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"roll": -0.009209918
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"roll": -0.012478679
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"roll": 0.031160546
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.8700758
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.4349194
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.4970688
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.9622451
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.9203311
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.6307226
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -0.2971223
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.8864816
}
}
]
}
}
@@ -0,0 +1,389 @@
{
"version": 1,
"valid": true,
"name": "facepalm",
"description": "just facepalm",
"author": "dsty",
"emote": {
"beginTick": 0,
"endTick": 60,
"stopTick": 61,
"isLoop": false,
"returnTick": 2,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.067995355
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.14453278
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.11871816
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.19018666
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.20064461
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.060454715
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.086299464
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.087619215
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.083825104
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.036273066
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0041144555
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"roll": 0.12942825
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"roll": -0.050149817
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"roll": 0.08701207
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"roll": 0.08676299
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"x": -3.8523226
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"x": -3.8523226
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.0664942
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.074402
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.074402
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.02932
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.02932
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.47555572
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.5093512
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.5093512
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.38563538
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.38563538
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.20147677
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.20521602
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.3274244
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.30787703
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.40478125
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.49199954
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.54730254
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.52019864
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
}
]
}
}
@@ -0,0 +1,697 @@
{
"version": 1,
"valid": true,
"name": "front_flip",
"description": "Parkour!!!!1",
"author": "DKinchik",
"emote": {
"beginTick": 0,
"endTick": 17,
"stopTick": 18,
"isLoop": false,
"returnTick": 1,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.7020477
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.7020477
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"x": -1.9
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"x": -1.9
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 11.169379
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 12.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 12.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"z": -0.39214998
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"z": 0.1
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"z": 0.1
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -1.9707682
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -1.9707682
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"roll": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.3665518
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.3665518
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0793308
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0793308
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"x": 1.9
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"x": 1.9
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 10.861001
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 12.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 12.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.5082285
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.1
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.1
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -1.9850811
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -1.9850811
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"x": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"x": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"y": -0.061333913
}
},
{
"tick": 7,
"easing": "OUTQUAD",
"body": {
"y": 0.48852682
}
},
{
"tick": 15,
"easing": "CONSTANT",
"body": {
"y": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"y": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"z": -0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"pitch": 0.0
}
},
{
"tick": 7,
"easing": "OUTQUAD",
"body": {
"pitch": -2.8362873
}
},
{
"tick": 15,
"easing": "CONSTANT",
"body": {
"pitch": -6.2831855
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"pitch": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"yaw": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"yaw": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"roll": -0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"body": {
"bend": -0.0
}
}
]
}
}
@@ -0,0 +1,354 @@
{
"version": 1,
"valid": true,
"name": "tpose",
"description": "MENACING",
"author": "stephanieisdumb",
"emote": {
"beginTick": 0,
"endTick": 80,
"stopTick": 81,
"isLoop": true,
"returnTick": 20,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"x": -6.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"x": -6.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"y": 3.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"y": 3.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.0704756
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": 0.059108086
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.61077195
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.008776078
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.6060502
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5707964
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5765934
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5707964
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -1.3806202
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"x": 6.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"x": 6.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"y": 3.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"y": 3.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0045553
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.010133168
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.44322997
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.038214765
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -0.5571526
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5707964
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5692185
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5707964
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -1.3810014
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": 0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": 0.0
}
}
]
}
}