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

- core: EmotecraftLoader reads Emotecraft / player-animation-lib JSON
  into a KeyframeAnimation (per-channel keyframes with Easing), following
  player-animation-lib's reader: absolute pivots become offsets, body
  (and torso before 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. Emote and EmoteRegistry hold emotes
  by ID with their name, description and author. Animation.loopStart
  lets loops restart after an intro. Uses Jackson 2.17.0, the version
  Saturn Client ships.
- Bone.ROOT moves the whole player around its middle (0.7 blocks above
  the feet), through the model's root part, so armor and held items
  follow.
- fabric: Emotes.registry() and Emotes.play(uuid, id). Core and Jackson
  are bundled in the jar.
- testmod: loads Saturn's six emotes into the library's registry
  (/emotes play), and keeps its test animations in a separate registry
  (/emotes test). The gametest shoots the loaded emotes too.

Blending with the vanilla pose is issue #4.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 20:51:52 +02:00
co-authored by claude
parent cf05623c54
commit e40aa1353b
33 changed files with 17596 additions and 36 deletions
+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
}
}
]
}
}