Compare commits
15
Commits
d5af8d65c2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa740ba6c | ||
|
|
c92abd4401 | ||
|
|
9d0fe6fb6b | ||
|
|
8794f3fb65 | ||
|
|
970e9aef51 | ||
|
|
0518a2793a | ||
|
|
df9a2587e6 | ||
|
|
7f8de39253 | ||
|
|
e40aa1353b | ||
|
|
cf05623c54 | ||
|
|
c3b0e86548 | ||
|
|
7016405e5f | ||
|
|
09cfd0cc10 | ||
|
|
34aba293b6 | ||
|
|
8bac5eef64 |
@@ -5,15 +5,57 @@ A player emote library for Saturn Client, with limb bending.
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `core/`: Minecraft-independent animation model (`Bone`, `BoneTransform`, `Pose`, `Animation`,
|
- `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
|
- `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
|
version with [Stonecutter](https://stonecutter.kikugie.dev/) and published as
|
||||||
`org.saturnclient:emotes-fabric:<version>+<mc>`, with `core` bundled inside the jar.
|
`org.saturnclient:emotes-fabric:<version>+<mc>`, with `core` and Jackson 2.17.0 (the version Saturn
|
||||||
- `src/testmod/`: a dev-only tester mod that is never published. It adds `/emotes play <name>`,
|
Client ships) bundled inside the jar.
|
||||||
`/emotes stop` and `/emotes info`. Press F5 to see yourself.
|
- `src/testmod/`: a dev-only tester mod that is never published. It loads Saturn's emotes into the
|
||||||
|
library's registry and keeps its own hand-written test animations in a separate registry.
|
||||||
|
`/emotes play <id>` plays a registered emote, `/emotes test <name>` a test animation, and there's
|
||||||
|
`/emotes stop` and `/emotes info`. Press F5 to see yourself. It also has a Fabric client gametest
|
||||||
|
that creates a world and screenshots each animation from the front and side, into
|
||||||
|
`versions/<mc>/build/run/clientGameTest/screenshots/`.
|
||||||
|
- `tools/capture/`: a Python tool that measures an emote from a store page's 3D preview and writes
|
||||||
|
it as Emotecraft JSON. See its README.
|
||||||
|
- `tools/emotecraft/convert.py`: converts Emotecraft's binary `.emotecraft` files to Emotecraft JSON
|
||||||
|
(standard-library Python).
|
||||||
|
|
||||||
Supported versions: 1.21.4 to 1.21.11, the same range as Saturn Client. Each version's Fabric API
|
Supported versions: 1.21.4 to 1.21.11, 26.1 (and its hotfixes), 26.2 and 26.3. Each version's
|
||||||
version is set in `stonecutter.properties.toml`. The code uses Mojang mappings.
|
Fabric API version is set in `stonecutter.properties.toml`. The code uses Mojang mappings, which
|
||||||
|
26.1+ ships as its own names, so `loom-back-compat` applies the remapping Loom for 1.21.x and the
|
||||||
|
plain one for 26.x. 26.x builds need Java 25; Gradle downloads it when it isn't installed.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
```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`.
|
||||||
|
Emotecraft's two-segment bends become our curved ones: a limb's bend changes sign, the torso's
|
||||||
|
doesn't, and a bend on the whole player bends the torso. Scale channels, `easingArg` and blending
|
||||||
|
with the vanilla pose aren't supported yet (issue #4). Binary `.emotecraft` files can be converted to
|
||||||
|
JSON first with `tools/emotecraft/convert.py`.
|
||||||
|
|
||||||
## How it hooks in
|
## How it hooks in
|
||||||
|
|
||||||
@@ -22,22 +64,48 @@ version is set in `stonecutter.properties.toml`. The code uses Mojang mappings.
|
|||||||
and the armor layers both go through this method, so armor follows the pose.
|
and the armor layers both go through this method, so armor follows the pose.
|
||||||
|
|
||||||
Both hook points exist unchanged in every supported version. Version-specific code goes behind
|
Both hook points exist unchanged in every supported version. Version-specific code goes behind
|
||||||
Stonecutter comments:
|
Stonecutter comments, like the vertex accessors in `BentCubeRenderer`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
//? if >=1.21.9 {
|
//? if >=1.21.9 {
|
||||||
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
|
return new float[] { vertex.x(), vertex.y(), vertex.z(), vertex.u(), vertex.v() };
|
||||||
//?} else
|
//?} else
|
||||||
/*import net.minecraft.client.renderer.entity.state.PlayerRenderState;*/
|
/*return new float[] { vertex.pos().x(), vertex.pos().y(), vertex.pos().z(), vertex.u(), vertex.v() };*/
|
||||||
```
|
```
|
||||||
|
|
||||||
Bending (`BoneTransform.bend` / `bendAxis`) is part of the model but isn't rendered yet.
|
## Bending
|
||||||
|
|
||||||
|
Each limb bends along a single axis (`BoneTransform.bend`, `bendAxis`). It works like the cloak
|
||||||
|
renderer: build a spine, put a ring of shared vertices along it, and draw the faces between the rings.
|
||||||
|
|
||||||
|
- `LimbBend` (core) runs the spine along a cubic Bézier (`Bezier`). The control points are the rigid
|
||||||
|
"upper half, joint, lower half" shape, and `sharpness` goes from a round curve (0) to a tight joint
|
||||||
|
(1). The spine is cut into one ring per texture row (12 per limb), spaced evenly by arc length, so
|
||||||
|
the limb keeps its length. Each ring moves its cross-section rigidly, so neighbouring pieces share
|
||||||
|
vertices and the mesh stays closed.
|
||||||
|
- `QuadSplitter` (core) cuts each vanilla face into strips at the rings and splits its UVs to match.
|
||||||
|
The texture is unchanged apart from the cuts.
|
||||||
|
- `ModelPartMixin` swaps in `BentCubeRenderer` for any part with a bend. That renderer deforms
|
||||||
|
vanilla's own cubes, so slim arms, sleeves, pants and armor all work. Overlays and armor use the
|
||||||
|
limb's spine, not their own inflated box, so they stay attached.
|
||||||
|
|
||||||
|
Axis 0 folds towards the front (-Z) and PI/2 towards +X, in the limb's own space. Knees fold back,
|
||||||
|
so they use axis PI.
|
||||||
|
|
||||||
|
The torso is anchored at the hips instead of its pivot (`LimbBend.anchoredAtEnd`), so the legs stay
|
||||||
|
attached and the chest leans. The head and arms are siblings of the body in the vanilla model, so
|
||||||
|
`PoseApplier` carries them: their pivots follow the bent torso, and they turn with its ring there
|
||||||
|
(`LimbBend.angleAt`). An arm carried by a forward lean swings back like a diver's, so emotes that want
|
||||||
|
hanging arms tilt them forward by about the lean (see the `twerk` test animation).
|
||||||
|
|
||||||
|
Held items still attach to the unbent arm, and the cape doesn't follow the torso yet.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./gradlew :core:test # core unit tests
|
./gradlew :core:test # core unit tests
|
||||||
./gradlew :1.21.11:runTestmodClient # launch the game with the tester (run dir: ./run)
|
./gradlew :1.21.11:runTestmodClient # launch the game with the tester (run dir: ./run)
|
||||||
|
./gradlew :1.21.11:runClientGameTest # screenshot each test animation, then quit
|
||||||
./gradlew :1.21.4:compileTestmodJava # quick compile check for one version
|
./gradlew :1.21.4:compileTestmodJava # quick compile check for one version
|
||||||
./gradlew buildAll # every version's jar → build/libs/<version>/
|
./gradlew buildAll # every version's jar → build/libs/<version>/
|
||||||
GITEA_TOKEN=... ./gradlew publish # publish core + every version to Gitea
|
GITEA_TOKEN=... ./gradlew publish # publish core + every version to Gitea
|
||||||
@@ -48,8 +116,8 @@ version whose code is uncommented in `src/`. Switch versions with the Stonecutte
|
|||||||
or the `Set active project to <v>` Gradle task. Run `Reset active project` before committing, so
|
or the `Set active project to <v>` Gradle task. Run `Reset active project` before committing, so
|
||||||
the tree is committed with `vcsVersion` (1.21.11) active.
|
the tree is committed with `vcsVersion` (1.21.11) active.
|
||||||
|
|
||||||
Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17 until the
|
Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17, which builds
|
||||||
toolchain moves. Minecraft 26.x will need that upgrade.
|
26.x as well (with a Java 25 toolchain for those versions only).
|
||||||
|
|
||||||
## Publishing
|
## Publishing
|
||||||
|
|
||||||
|
|||||||
+28
-9
@@ -22,7 +22,8 @@ val testmod: SourceSet = sourceSets.create("testmod") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loom {
|
loom {
|
||||||
createRemapConfigurations(testmod)
|
// The testmod's mod* configurations; 26.1+ has nothing to remap, and loomx aliases them there.
|
||||||
|
if (!loomx.isUnobfuscated) createRemapConfigurations(testmod)
|
||||||
|
|
||||||
mods {
|
mods {
|
||||||
register("saturn_emotes") { sourceSet(sourceSets.main.get()) }
|
register("saturn_emotes") { sourceSet(sourceSets.main.get()) }
|
||||||
@@ -33,6 +34,13 @@ loom {
|
|||||||
options.put("mark-corresponding-synthetics", "1") // Names lambdas, useful for mixins
|
options.put("mark-corresponding-synthetics", "1") // Names lambdas, useful for mixins
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runConfigs.all {
|
||||||
|
preferGradleTask = true
|
||||||
|
generateRunConfig = true
|
||||||
|
runDirectory = rootProject.file("run") // Shared by every version
|
||||||
|
jvmArguments.add("-Dmixin.debug.export=true")
|
||||||
|
}
|
||||||
|
|
||||||
runs {
|
runs {
|
||||||
// ./gradlew :<mc>:runTestmodClient launches the game with the tester mod loaded.
|
// ./gradlew :<mc>:runTestmodClient launches the game with the tester mod loaded.
|
||||||
register("testmodClient") {
|
register("testmodClient") {
|
||||||
@@ -40,13 +48,15 @@ loom {
|
|||||||
name = "Testmod Client"
|
name = "Testmod Client"
|
||||||
source(testmod)
|
source(testmod)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
runConfigs.all {
|
// ./gradlew :<mc>:runClientGameTest screenshots the test animations, then quits.
|
||||||
preferGradleTask = true
|
register("clientGameTest") {
|
||||||
generateRunConfig = true
|
client()
|
||||||
runDirectory = rootProject.file("run") // Shared by every version
|
name = "Client Game Test"
|
||||||
jvmArguments.add("-Dmixin.debug.export=true")
|
source(testmod)
|
||||||
|
vmArg("-Dfabric.client.gametest")
|
||||||
|
runDir = "build/run/clientGameTest"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,11 +71,20 @@ dependencies {
|
|||||||
modImplementation("net.fabricmc:fabric-loader:${property("deps.fabric_loader")}")
|
modImplementation("net.fabricmc:fabric-loader:${property("deps.fabric_loader")}")
|
||||||
fapi("fabric-api-base", "fabric-lifecycle-events-v1")
|
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"))
|
api(project(":core"))
|
||||||
include(project(":core"))
|
include(project(":core"))
|
||||||
|
for (module in listOf("jackson-databind", "jackson-core", "jackson-annotations")) {
|
||||||
|
include("com.fasterxml.jackson.core:$module:2.17.0")
|
||||||
|
}
|
||||||
|
|
||||||
"modTestmodImplementation"(fabricApi.module("fabric-command-api-v2", sc.properties["deps.fabric_api"]))
|
for (module in listOf("fabric-command-api-v2", "fabric-client-gametest-api-v1")) {
|
||||||
|
"modTestmodImplementation"(fabricApi.module(module, sc.properties["deps.fabric_api"]))
|
||||||
|
}
|
||||||
|
// Single modules don't bring their Fabric API dependencies (which differ between versions),
|
||||||
|
// so the tester runs with all of Fabric API.
|
||||||
|
"modTestmodRuntimeOnly"("net.fabricmc.fabric-api:fabric-api:${sc.properties.get<String>("deps.fabric_api")}")
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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(platform("org.junit:junit-bom:5.13.4"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
|||||||
@@ -14,4 +14,12 @@ public interface Animation {
|
|||||||
default boolean loops() {
|
default boolean loops() {
|
||||||
return false;
|
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) {
|
public float time(long tick, float partialTick) {
|
||||||
float elapsed = Math.max(0, tick - startTick + partialTick);
|
float elapsed = Math.max(0, tick - startTick + partialTick);
|
||||||
float length = animation.length();
|
float length = animation.length();
|
||||||
if (animation.loops() && length > 0) {
|
if (!animation.loops()) {
|
||||||
return elapsed % length;
|
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) {
|
public boolean isFinished(long tick) {
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ package org.saturnclient.emotes.core;
|
|||||||
|
|
||||||
/** The parts of the player model an animation can move. */
|
/** The parts of the player model an animation can move. */
|
||||||
public enum Bone {
|
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,
|
HEAD,
|
||||||
|
/** The torso. It bends from the hips; see {@code LimbBend#anchoredAtEnd}. */
|
||||||
BODY,
|
BODY,
|
||||||
RIGHT_ARM,
|
RIGHT_ARM,
|
||||||
LEFT_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,51 @@
|
|||||||
|
package org.saturnclient.emotes.core.bend;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** A Bézier curve of any degree, evaluated with De Casteljau's algorithm. */
|
||||||
|
public final class Bezier {
|
||||||
|
private final Vec3[] points;
|
||||||
|
|
||||||
|
public Bezier(List<Vec3> controlPoints) {
|
||||||
|
if (controlPoints.size() < 2) {
|
||||||
|
throw new IllegalArgumentException("A Bézier curve needs at least two control points");
|
||||||
|
}
|
||||||
|
this.points = controlPoints.toArray(Vec3[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Bezier of(Vec3... controlPoints) {
|
||||||
|
return new Bezier(List.of(controlPoints));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int degree() {
|
||||||
|
return points.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Vec3> controlPoints() {
|
||||||
|
return List.of(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The point at {@code t} in [0, 1]. */
|
||||||
|
public Vec3 point(float t) {
|
||||||
|
return deCasteljau(points, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first derivative at {@code t}: the tangent, scaled by the curve's speed. */
|
||||||
|
public Vec3 derivative(float t) {
|
||||||
|
Vec3[] hodograph = new Vec3[points.length - 1];
|
||||||
|
for (int i = 0; i < hodograph.length; i++) {
|
||||||
|
hodograph[i] = points[i + 1].sub(points[i]).scale(degree());
|
||||||
|
}
|
||||||
|
return deCasteljau(hodograph, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vec3 deCasteljau(Vec3[] controlPoints, float t) {
|
||||||
|
Vec3[] work = controlPoints.clone();
|
||||||
|
for (int level = work.length - 1; level > 0; level--) {
|
||||||
|
for (int i = 0; i < level; i++) {
|
||||||
|
work[i] = work[i].lerp(work[i + 1], t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return work[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package org.saturnclient.emotes.core.bend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bends one limb along a single axis, the way an elbow or knee folds.
|
||||||
|
*
|
||||||
|
* <p>Coordinates are the limb's model space in pixels: the limb hangs along +Y from {@code start}
|
||||||
|
* to {@code end}, and its front faces -Z. At rest, the spine runs down the limb's center (x = z = 0).
|
||||||
|
* When bent, the spine follows a cubic Bézier curve whose control polygon is the rigid "upper half,
|
||||||
|
* joint, lower half" shape, so the upper and lower halves stay nearly straight and the joint curves.
|
||||||
|
*
|
||||||
|
* <p>The spine is split into {@code segments} rings, spaced evenly by arc length so the limb keeps its
|
||||||
|
* length and each piece covers the same texture rows as before. Each ring moves its cross-section
|
||||||
|
* rigidly, so neighbouring pieces share their vertices and the mesh stays closed however far it bends.
|
||||||
|
*
|
||||||
|
* <p>A limb is normally anchored at {@code start}, its pivot. The torso is anchored at {@code end}
|
||||||
|
* instead: the hips stay on the legs and the upper half leans (see {@link #anchoredAtEnd}).
|
||||||
|
*/
|
||||||
|
public final class LimbBend {
|
||||||
|
/** Samples used to measure the curve's arc length. */
|
||||||
|
private static final int ARC_SAMPLES = 64;
|
||||||
|
|
||||||
|
private final float start;
|
||||||
|
private final float length;
|
||||||
|
/** Whether the fixed end is {@code end}. Then everything is mirrored along Y around the middle. */
|
||||||
|
private final boolean anchoredAtEnd;
|
||||||
|
private final int segments;
|
||||||
|
private final float angle;
|
||||||
|
/** Unit bend direction D in the XZ plane, and the rotation axis n = Y × D. */
|
||||||
|
private final float dirX, dirZ;
|
||||||
|
private final float axisX, axisZ;
|
||||||
|
|
||||||
|
/** Ring centers and their rotation around n, for rings 0..segments. */
|
||||||
|
private final float[] ringX, ringY, ringZ, ringAngle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param start top of the limb (its pivot end), along Y
|
||||||
|
* @param end bottom of the limb, along Y
|
||||||
|
* @param angle how far the lower half folds, in radians
|
||||||
|
* @param axis direction the limb folds towards, as an angle around the limb: 0 folds to the
|
||||||
|
* front (-Z) and PI/2 folds to +X
|
||||||
|
* @param sharpness 0 for a long, round curve (a quadratic Bézier) up to 1 for a tight joint
|
||||||
|
* @param segments number of pieces the limb is divided into
|
||||||
|
*/
|
||||||
|
public LimbBend(float start, float end, float angle, float axis, float sharpness, int segments) {
|
||||||
|
this(start, end, angle, axis, sharpness, segments, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link #LimbBend(float, float, float, float, float, int)}, but {@code end} stays in place and
|
||||||
|
* the half towards {@code start} folds. Axis 0 still folds towards the front.
|
||||||
|
*/
|
||||||
|
public static LimbBend anchoredAtEnd(float start, float end, float angle, float axis, float sharpness,
|
||||||
|
int segments) {
|
||||||
|
return new LimbBend(start, end, angle, axis, sharpness, segments, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LimbBend(float start, float end, float angle, float axis, float sharpness, int segments,
|
||||||
|
boolean anchoredAtEnd) {
|
||||||
|
if (end <= start) {
|
||||||
|
throw new IllegalArgumentException("end must be below start");
|
||||||
|
}
|
||||||
|
if (segments < 1) {
|
||||||
|
throw new IllegalArgumentException("segments must be at least 1");
|
||||||
|
}
|
||||||
|
this.start = start;
|
||||||
|
this.length = end - start;
|
||||||
|
this.anchoredAtEnd = anchoredAtEnd;
|
||||||
|
this.segments = segments;
|
||||||
|
this.angle = angle;
|
||||||
|
this.dirX = (float) Math.sin(axis);
|
||||||
|
this.dirZ = (float) -Math.cos(axis);
|
||||||
|
// n = Y × D = (0, 1, 0) × (dirX, 0, dirZ)
|
||||||
|
this.axisX = dirZ;
|
||||||
|
this.axisZ = -dirX;
|
||||||
|
|
||||||
|
ringX = new float[segments + 1];
|
||||||
|
ringY = new float[segments + 1];
|
||||||
|
ringZ = new float[segments + 1];
|
||||||
|
ringAngle = new float[segments + 1];
|
||||||
|
|
||||||
|
if (isStraight()) {
|
||||||
|
for (int i = 0; i <= segments; i++) {
|
||||||
|
ringY[i] = start + length * i / segments;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buildRings(spine(Math.max(0, Math.min(1, sharpness))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the limb is (close enough to) unbent. Straight limbs can be drawn as they are. */
|
||||||
|
public boolean isStraight() {
|
||||||
|
return Math.abs(angle) < 1e-4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int segments() {
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top of the limb along Y (its pivot end), as given to the constructor. */
|
||||||
|
public float start() {
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bottom of the limb along Y (the hand or foot end), as given to the constructor. */
|
||||||
|
public float end() {
|
||||||
|
return start + length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Y positions where the limb is cut into pieces, not counting its ends. */
|
||||||
|
public float[] cuts() {
|
||||||
|
float[] cuts = new float[segments - 1];
|
||||||
|
for (int i = 1; i < segments; i++) {
|
||||||
|
cuts[i - 1] = start + length * i / segments;
|
||||||
|
}
|
||||||
|
return cuts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bezier spine(float sharpness) {
|
||||||
|
float half = length / 2;
|
||||||
|
Vec3 top = new Vec3(0, start, 0);
|
||||||
|
Vec3 joint = new Vec3(0, start + half, 0);
|
||||||
|
Vec3 lower = new Vec3(dirX * (float) Math.sin(angle), (float) Math.cos(angle), dirZ * (float) Math.sin(angle));
|
||||||
|
Vec3 bottom = joint.add(lower.scale(half));
|
||||||
|
// At sharpness 0 this is the quadratic (top, joint, bottom) raised to a cubic.
|
||||||
|
float pull = 2f / 3 + sharpness / 3;
|
||||||
|
return Bezier.of(top, top.lerp(joint, pull), bottom.lerp(joint, pull), bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildRings(Bezier curve) {
|
||||||
|
// Arc length table, so rings can be spaced evenly along the curve.
|
||||||
|
float[] ts = new float[ARC_SAMPLES + 1];
|
||||||
|
float[] lengths = new float[ARC_SAMPLES + 1];
|
||||||
|
Vec3 previous = curve.point(0);
|
||||||
|
for (int i = 1; i <= ARC_SAMPLES; i++) {
|
||||||
|
ts[i] = (float) i / ARC_SAMPLES;
|
||||||
|
Vec3 point = curve.point(ts[i]);
|
||||||
|
lengths[i] = lengths[i - 1] + point.sub(previous).length();
|
||||||
|
previous = point;
|
||||||
|
}
|
||||||
|
// The curve cuts the corner, so it's shorter than the limb. Scale it about the top to fit.
|
||||||
|
float scale = length / lengths[ARC_SAMPLES];
|
||||||
|
Vec3 top = curve.point(0);
|
||||||
|
Vec3 dir = new Vec3(dirX, 0, dirZ);
|
||||||
|
|
||||||
|
int sample = 0;
|
||||||
|
for (int i = 0; i <= segments; i++) {
|
||||||
|
float target = lengths[ARC_SAMPLES] * i / segments;
|
||||||
|
while (sample < ARC_SAMPLES - 1 && lengths[sample + 1] < target) {
|
||||||
|
sample++;
|
||||||
|
}
|
||||||
|
float span = lengths[sample + 1] - lengths[sample];
|
||||||
|
float f = span > 0 ? (target - lengths[sample]) / span : 0;
|
||||||
|
float t = ts[sample] + (ts[sample + 1] - ts[sample]) * f;
|
||||||
|
|
||||||
|
Vec3 center = top.add(curve.point(t).sub(top).scale(scale));
|
||||||
|
Vec3 tangent = curve.derivative(t);
|
||||||
|
ringX[i] = center.x();
|
||||||
|
ringY[i] = center.y();
|
||||||
|
ringZ[i] = center.z();
|
||||||
|
ringAngle[i] = (float) Math.atan2(tangent.dot(dir), tangent.y());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a point from the straight limb onto the bent one. Points above or below the limb follow
|
||||||
|
* the end rings' tangents, so overlay layers and armor that stick out past the limb stay attached.
|
||||||
|
*
|
||||||
|
* @param out receives the bent position as {x, y, z}
|
||||||
|
*/
|
||||||
|
public void bendPoint(float x, float y, float z, float[] out) {
|
||||||
|
if (anchoredAtEnd) {
|
||||||
|
bendPointFromStart(x, mirror(y), z, out);
|
||||||
|
out[1] = mirror(out[1]);
|
||||||
|
} else {
|
||||||
|
bendPointFromStart(x, y, z, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotates a direction (a face normal) the same way {@link #bendPoint} rotates points at {@code atY}. */
|
||||||
|
public void bendDirection(float atY, float nx, float ny, float nz, float[] out) {
|
||||||
|
if (anchoredAtEnd) {
|
||||||
|
rotate(nx, -ny, nz, angleFromStart(mirror(atY)), out);
|
||||||
|
out[1] = -out[1];
|
||||||
|
} else {
|
||||||
|
rotate(nx, ny, nz, angleFromStart(atY), out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the limb has turned at {@code y}, as a rotation around {@link #axis()} by this angle.
|
||||||
|
* Parts attached to the limb at {@code y} (like the head and arms on the torso) turn by this much.
|
||||||
|
*/
|
||||||
|
public float angleAt(float y) {
|
||||||
|
// Mirroring along Y turns a rotation around a horizontal axis the other way.
|
||||||
|
return anchoredAtEnd ? -angleFromStart(mirror(y)) : angleFromStart(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The unit axis the limb turns around. It's horizontal, at right angles to the bend direction. */
|
||||||
|
public Vec3 axis() {
|
||||||
|
return new Vec3(axisX, 0, axisZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float mirror(float y) {
|
||||||
|
return 2 * start + length - y;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bendPointFromStart(float x, float y, float z, float[] out) {
|
||||||
|
float along = (y - start) / length * segments;
|
||||||
|
int ring;
|
||||||
|
float f;
|
||||||
|
float extra = 0;
|
||||||
|
if (along <= 0) {
|
||||||
|
ring = 0;
|
||||||
|
f = 0;
|
||||||
|
extra = y - start;
|
||||||
|
} else if (along >= segments) {
|
||||||
|
ring = segments - 1;
|
||||||
|
f = 1;
|
||||||
|
extra = y - (start + length);
|
||||||
|
} else {
|
||||||
|
ring = Math.min((int) along, segments - 1);
|
||||||
|
f = along - ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
float cx = lerp(ringX[ring], ringX[ring + 1], f);
|
||||||
|
float cy = lerp(ringY[ring], ringY[ring + 1], f);
|
||||||
|
float cz = lerp(ringZ[ring], ringZ[ring + 1], f);
|
||||||
|
float a = lerp(ringAngle[ring], ringAngle[ring + 1], f);
|
||||||
|
|
||||||
|
// The cross-section offset (x, extra, z) rotates with the ring. Past the ends, "extra"
|
||||||
|
// carries on along the end tangent.
|
||||||
|
rotate(x, extra, z, a, out);
|
||||||
|
out[0] += cx;
|
||||||
|
out[1] += cy;
|
||||||
|
out[2] += cz;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float angleFromStart(float y) {
|
||||||
|
float along = Math.max(0, Math.min(segments, (y - start) / length * segments));
|
||||||
|
int ring = Math.min((int) along, segments - 1);
|
||||||
|
return lerp(ringAngle[ring], ringAngle[ring + 1], along - ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rodrigues' rotation of v around the bend axis n by {@code a}. */
|
||||||
|
private void rotate(float x, float y, float z, float a, float[] out) {
|
||||||
|
float cos = (float) Math.cos(a);
|
||||||
|
float sin = (float) Math.sin(a);
|
||||||
|
// n = (axisX, 0, axisZ)
|
||||||
|
float crossX = -axisZ * y;
|
||||||
|
float crossY = axisZ * x - axisX * z;
|
||||||
|
float crossZ = axisX * y;
|
||||||
|
float dot = axisX * x + axisZ * z;
|
||||||
|
out[0] = x * cos + crossX * sin + axisX * dot * (1 - cos);
|
||||||
|
out[1] = y * cos + crossY * sin;
|
||||||
|
out[2] = z * cos + crossZ * sin + axisZ * dot * (1 - cos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float lerp(float a, float b, float t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package org.saturnclient.emotes.core.bend;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuts the faces of a box into horizontal strips so they can bend.
|
||||||
|
*
|
||||||
|
* <p>A quad is four vertices of {x, y, z, u, v}. Faces that run along Y (the sides of a limb) are cut
|
||||||
|
* at every Y in {@code cuts}, and each strip's UVs are interpolated along the face, so the strips
|
||||||
|
* together cover the same texture as the original face. Flat faces (the limb's ends) are kept as they
|
||||||
|
* are. Strips keep the original winding.
|
||||||
|
*/
|
||||||
|
public final class QuadSplitter {
|
||||||
|
private static final float EPSILON = 1e-4f;
|
||||||
|
|
||||||
|
private QuadSplitter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<float[][]> split(float[][] quad, float[] cuts) {
|
||||||
|
float minY = Float.MAX_VALUE;
|
||||||
|
float maxY = -Float.MAX_VALUE;
|
||||||
|
for (float[] vertex : quad) {
|
||||||
|
minY = Math.min(minY, vertex[1]);
|
||||||
|
maxY = Math.max(maxY, vertex[1]);
|
||||||
|
}
|
||||||
|
if (maxY - minY < EPSILON) {
|
||||||
|
return List.<float[][]>of(quad);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each vertex's partner is its neighbour at the other end of the face's vertical edge.
|
||||||
|
int[] partner = new int[4];
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
int next = (i + 1) % 4;
|
||||||
|
int prev = (i + 3) % 4;
|
||||||
|
partner[i] = Math.abs(quad[next][1] - quad[i][1]) > EPSILON ? next : prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Float> bounds = new ArrayList<>();
|
||||||
|
bounds.add(minY);
|
||||||
|
for (float cut : cuts) {
|
||||||
|
if (cut > minY + EPSILON && cut < maxY - EPSILON) {
|
||||||
|
bounds.add(cut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bounds.add(maxY);
|
||||||
|
|
||||||
|
List<float[][]> strips = new ArrayList<>(bounds.size() - 1);
|
||||||
|
for (int s = 0; s + 1 < bounds.size(); s++) {
|
||||||
|
float top = bounds.get(s);
|
||||||
|
float bottom = bounds.get(s + 1);
|
||||||
|
float[][] strip = new float[4][];
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
float[] from = quad[i];
|
||||||
|
float[] to = quad[partner[i]];
|
||||||
|
float target = from[1] < to[1] ? top : bottom;
|
||||||
|
strip[i] = lerp(from, to, (target - from[1]) / (to[1] - from[1]));
|
||||||
|
}
|
||||||
|
strips.add(strip);
|
||||||
|
}
|
||||||
|
return strips;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] lerp(float[] a, float[] b, float t) {
|
||||||
|
float[] out = new float[a.length];
|
||||||
|
for (int i = 0; i < a.length; i++) {
|
||||||
|
out[i] = a[i] + (b[i] - a[i]) * t;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package org.saturnclient.emotes.core.bend;
|
||||||
|
|
||||||
|
/** A small immutable 3D vector, in model pixels. */
|
||||||
|
public record Vec3(float x, float y, float z) {
|
||||||
|
public static final Vec3 ZERO = new Vec3(0, 0, 0);
|
||||||
|
|
||||||
|
public Vec3 add(Vec3 o) {
|
||||||
|
return new Vec3(x + o.x, y + o.y, z + o.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vec3 sub(Vec3 o) {
|
||||||
|
return new Vec3(x - o.x, y - o.y, z - o.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vec3 scale(float s) {
|
||||||
|
return new Vec3(x * s, y * s, z * s);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vec3 lerp(Vec3 o, float t) {
|
||||||
|
return new Vec3(x + (o.x - x) * t, y + (o.y - y) * t, z + (o.z - z) * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
public float dot(Vec3 o) {
|
||||||
|
return x * o.x + y * o.y + z * o.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vec3 cross(Vec3 o) {
|
||||||
|
return new Vec3(y * o.z - z * o.y, z * o.x - x * o.z, x * o.y - y * o.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
public float length() {
|
||||||
|
return (float) Math.sqrt(dot(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Vec3 normalize() {
|
||||||
|
float length = length();
|
||||||
|
return length < 1e-7f ? ZERO : scale(1 / length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,179 @@
|
|||||||
|
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 limbs the other way, so a limb's {@code bend} changes sign. The torso's doesn't:
|
||||||
|
* ours is anchored at the hips, which already turns it round, so the two agree (a positive bend with
|
||||||
|
* axis 0 leans forward in both).</li>
|
||||||
|
* <li>The whole player can't bend. In Emotecraft its {@code bend} and {@code axis} bend the torso,
|
||||||
|
* so they go to {@link Bone#BODY}.</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()) {
|
||||||
|
Channel channel = axis.getValue();
|
||||||
|
boolean bend = channel == Channel.BEND || channel == Channel.BEND_AXIS;
|
||||||
|
Bone target = bone == Bone.ROOT && bend ? Bone.BODY : bone;
|
||||||
|
float converted = convert(target, channel, value.floatValue(), degrees, turn);
|
||||||
|
animation.keyframe(target, channel, 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 -> bone == Bone.BODY ? angle(value, degrees, turn) : -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);
|
AnimationPlayer player = new AnimationPlayer(headSpin(20, false), 100);
|
||||||
assertEquals(0f, player.time(90, 0), 1e-6);
|
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,47 @@
|
|||||||
|
package org.saturnclient.emotes.core.bend;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class BezierTest {
|
||||||
|
private static void assertVec(Vec3 expected, Vec3 actual) {
|
||||||
|
assertEquals(expected.x(), actual.x(), 1e-5);
|
||||||
|
assertEquals(expected.y(), actual.y(), 1e-5);
|
||||||
|
assertEquals(expected.z(), actual.z(), 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void passesThroughEndpoints() {
|
||||||
|
Bezier curve = Bezier.of(new Vec3(0, 0, 0), new Vec3(1, 2, 0), new Vec3(3, 2, 1), new Vec3(4, 0, 2));
|
||||||
|
assertVec(new Vec3(0, 0, 0), curve.point(0));
|
||||||
|
assertVec(new Vec3(4, 0, 2), curve.point(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void quadraticMidpoint() {
|
||||||
|
Bezier curve = Bezier.of(new Vec3(0, 0, 0), new Vec3(1, 2, 0), new Vec3(2, 0, 0));
|
||||||
|
// B(0.5) = 0.25 P0 + 0.5 P1 + 0.25 P2
|
||||||
|
assertVec(new Vec3(1, 1, 0), curve.point(0.5f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void endTangentsFollowControlPolygon() {
|
||||||
|
Bezier curve = Bezier.of(new Vec3(0, 0, 0), new Vec3(0, 1, 0), new Vec3(1, 1, 0), new Vec3(2, 1, 0));
|
||||||
|
// B'(0) = n (P1 - P0), B'(1) = n (P3 - P2)
|
||||||
|
assertVec(new Vec3(0, 3, 0), curve.derivative(0));
|
||||||
|
assertVec(new Vec3(3, 0, 0), curve.derivative(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void derivativeMatchesFiniteDifference() {
|
||||||
|
Bezier curve = Bezier.of(new Vec3(0, 0, 0), new Vec3(1, 3, -1), new Vec3(2, -1, 4), new Vec3(5, 2, 0));
|
||||||
|
float t = 0.37f;
|
||||||
|
float h = 1e-3f;
|
||||||
|
Vec3 numeric = curve.point(t + h).sub(curve.point(t - h)).scale(1 / (2 * h));
|
||||||
|
Vec3 exact = curve.derivative(t);
|
||||||
|
assertEquals(exact.x(), numeric.x(), 1e-2);
|
||||||
|
assertEquals(exact.y(), numeric.y(), 1e-2);
|
||||||
|
assertEquals(exact.z(), numeric.z(), 1e-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package org.saturnclient.emotes.core.bend;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class LimbBendTest {
|
||||||
|
private static final float HALF_PI = (float) (Math.PI / 2);
|
||||||
|
|
||||||
|
/** An arm: pivot at the shoulder, box from y = -2 to 10. */
|
||||||
|
private static LimbBend arm(float angle, float axis) {
|
||||||
|
return new LimbBend(-2, 10, angle, axis, 0.5f, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float[] bend(LimbBend bend, float x, float y, float z) {
|
||||||
|
float[] out = new float[3];
|
||||||
|
bend.bendPoint(x, y, z, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float distance(float[] a, float[] b) {
|
||||||
|
float dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2];
|
||||||
|
return (float) Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void zeroBendLeavesPointsInPlace() {
|
||||||
|
LimbBend straight = arm(0, 0);
|
||||||
|
assertTrue(straight.isStraight());
|
||||||
|
assertArrayEquals(new float[] { 1.5f, 7, -2 }, bend(straight, 1.5f, 7, -2), 1e-5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cutsDivideTheLimbEvenly() {
|
||||||
|
float[] cuts = arm(1, 0).cuts();
|
||||||
|
assertEquals(11, cuts.length);
|
||||||
|
assertEquals(-1, cuts[0], 1e-5);
|
||||||
|
assertEquals(9, cuts[10], 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void topStaysAttachedToThePivot() {
|
||||||
|
LimbBend bend = arm(HALF_PI, 0);
|
||||||
|
assertArrayEquals(new float[] { 2, -2, 2 }, bend(bend, 2, -2, 2), 1e-4f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void spineKeepsItsLength() {
|
||||||
|
LimbBend bend = arm(HALF_PI, 0);
|
||||||
|
float total = 0;
|
||||||
|
float[] previous = bend(bend, 0, -2, 0);
|
||||||
|
for (int i = 1; i <= 12; i++) {
|
||||||
|
float[] point = bend(bend, 0, -2 + i, 0);
|
||||||
|
total += distance(previous, point);
|
||||||
|
previous = point;
|
||||||
|
}
|
||||||
|
// Rings are chords of the curve, so they come out a hair shorter than its arc.
|
||||||
|
assertEquals(12, total, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardBendMovesTheHandForward() {
|
||||||
|
// Axis 0 folds towards -Z, the front of the model.
|
||||||
|
float[] hand = bend(arm(HALF_PI, 0), 0, 10, 0);
|
||||||
|
assertTrue(hand[2] < -4, "hand should be in front, z = " + hand[2]);
|
||||||
|
assertEquals(0, hand[0], 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void axisTurnsTheBendDirection() {
|
||||||
|
float[] hand = bend(arm(HALF_PI, HALF_PI), 0, 10, 0);
|
||||||
|
assertTrue(hand[0] > 4, "hand should be towards +X, x = " + hand[0]);
|
||||||
|
assertEquals(0, hand[2], 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void endPointsFollowTheLowerHalf() {
|
||||||
|
// Past the bottom of the limb, points carry on along the forearm, which points forward at 90°.
|
||||||
|
LimbBend bend = arm(HALF_PI, 0);
|
||||||
|
float[] bottom = bend(bend, 0, 10, 0);
|
||||||
|
float[] below = bend(bend, 0, 11, 0);
|
||||||
|
assertEquals(0, below[1] - bottom[1], 0.05);
|
||||||
|
assertEquals(-1, below[2] - bottom[2], 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ringsMoveRigidly() {
|
||||||
|
// Every point on one ring keeps its distance from the ring's center.
|
||||||
|
LimbBend bend = arm(1.2f, 0.7f);
|
||||||
|
float[] center = bend(bend, 0, 4, 0);
|
||||||
|
float[] corner = bend(bend, 2, 4, -2);
|
||||||
|
assertEquals(Math.sqrt(8), distance(center, corner), 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void neighbouringPiecesShareVertices() {
|
||||||
|
// A vertex on a cut is reached the same way from the piece above and the piece below.
|
||||||
|
LimbBend bend = arm(2, 0);
|
||||||
|
float[] fromAbove = bend(bend, 2, 3.99999f, 2);
|
||||||
|
float[] fromBelow = bend(bend, 2, 4.00001f, 2);
|
||||||
|
assertEquals(0, distance(fromAbove, fromBelow), 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalsRotateWithTheLimb() {
|
||||||
|
// The front face's normal (-Z) points up (-Y) on the forearm after a 90° forward bend.
|
||||||
|
float[] normal = new float[3];
|
||||||
|
arm(HALF_PI, 0).bendDirection(10, 0, 0, -1, normal);
|
||||||
|
assertArrayEquals(new float[] { 0, -1, 0 }, normal, 1e-3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A torso: box from y = 0 (neck) to 12 (hips), anchored at the hips. */
|
||||||
|
private static LimbBend torso(float angle, float axis) {
|
||||||
|
return LimbBend.anchoredAtEnd(0, 12, angle, axis, 0.5f, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void torsoKeepsItsHipsInPlace() {
|
||||||
|
assertArrayEquals(new float[] { 3, 12, -1 }, bend(torso(1, 0), 3, 12, -1), 1e-4f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void torsoLeansForward() {
|
||||||
|
// Axis 0 still means forward: the neck moves to the front (-Z) and down (+Y).
|
||||||
|
float[] neck = bend(torso(HALF_PI, 0), 0, 0, 0);
|
||||||
|
assertTrue(neck[2] < -4, "neck should be in front, z = " + neck[2]);
|
||||||
|
assertTrue(neck[1] > 4, "neck should drop, y = " + neck[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void torsoKeepsItsLength() {
|
||||||
|
LimbBend bend = torso(HALF_PI, 0);
|
||||||
|
float total = 0;
|
||||||
|
float[] previous = bend(bend, 0, 0, 0);
|
||||||
|
for (int i = 1; i <= 12; i++) {
|
||||||
|
float[] point = bend(bend, 0, i, 0);
|
||||||
|
total += distance(previous, point);
|
||||||
|
previous = point;
|
||||||
|
}
|
||||||
|
assertEquals(12, total, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attachedPartsTurnWithTheRing() {
|
||||||
|
// The offset from a ring's center to a point on it turns by angleAt around axis.
|
||||||
|
for (LimbBend bend : new LimbBend[] { arm(1.3f, 0.4f), torso(1.3f, 0.4f) }) {
|
||||||
|
for (float y : new float[] { -2, 0, 4, 10, 12 }) {
|
||||||
|
float[] center = bend(bend, 0, y, 0);
|
||||||
|
float[] point = bend(bend, 1, y, -2);
|
||||||
|
float[] expected = rotate(new float[] { 1, 0, -2 }, bend.axis(), bend.angleAt(y));
|
||||||
|
assertArrayEquals(expected,
|
||||||
|
new float[] { point[0] - center[0], point[1] - center[1], point[2] - center[2] }, 1e-3f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void torsoNormalsTurnWithTheRing() {
|
||||||
|
// A forward lean tips the front face (-Z) of the chest towards the ground (+Y).
|
||||||
|
float[] normal = new float[3];
|
||||||
|
torso(HALF_PI, 0).bendDirection(0, 0, 0, -1, normal);
|
||||||
|
assertArrayEquals(new float[] { 0, 1, 0 }, normal, 1e-3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rodrigues' rotation of v around the unit axis n. */
|
||||||
|
private static float[] rotate(float[] v, Vec3 n, float angle) {
|
||||||
|
Vec3 vec = new Vec3(v[0], v[1], v[2]);
|
||||||
|
float cos = (float) Math.cos(angle);
|
||||||
|
float sin = (float) Math.sin(angle);
|
||||||
|
Vec3 r = vec.scale(cos).add(n.cross(vec).scale(sin)).add(n.scale(n.dot(vec) * (1 - cos)));
|
||||||
|
return new float[] { r.x(), r.y(), r.z() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package org.saturnclient.emotes.core.bend;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class QuadSplitterTest {
|
||||||
|
// Front face of a 4 x 12 limb from y = 0 to 12, texture rows 20 to 32.
|
||||||
|
private static final float[][] FRONT = {
|
||||||
|
{ 2, 0, -2, 8, 20 },
|
||||||
|
{ -2, 0, -2, 4, 20 },
|
||||||
|
{ -2, 12, -2, 4, 32 },
|
||||||
|
{ 2, 12, -2, 8, 32 },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final float[] CUTS = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 };
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void splitsSideFacesIntoOneStripPerSegment() {
|
||||||
|
List<float[][]> strips = QuadSplitter.split(FRONT, CUTS);
|
||||||
|
assertEquals(12, strips.size());
|
||||||
|
assertArrayEquals(new float[] { 2, 5, -2, 8, 25 }, strips.get(5)[0], 1e-5f);
|
||||||
|
assertArrayEquals(new float[] { -2, 6, -2, 4, 26 }, strips.get(5)[2], 1e-5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsCoverTheFaceWithoutGaps() {
|
||||||
|
List<float[][]> strips = QuadSplitter.split(FRONT, CUTS);
|
||||||
|
for (int i = 0; i + 1 < strips.size(); i++) {
|
||||||
|
// The bottom edge of one strip is the top edge of the next.
|
||||||
|
assertArrayEquals(strips.get(i)[3], strips.get(i + 1)[0], 1e-5f);
|
||||||
|
assertArrayEquals(strips.get(i)[2], strips.get(i + 1)[1], 1e-5f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsFlatFaces() {
|
||||||
|
float[][] top = {
|
||||||
|
{ 2, 0, -2, 4, 16 },
|
||||||
|
{ -2, 0, -2, 8, 16 },
|
||||||
|
{ -2, 0, 2, 8, 20 },
|
||||||
|
{ 2, 0, 2, 4, 20 },
|
||||||
|
};
|
||||||
|
List<float[][]> strips = QuadSplitter.split(top, CUTS);
|
||||||
|
assertEquals(1, strips.size());
|
||||||
|
assertSame(top, strips.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void overlaysThatStickOutGetShorterEndStrips() {
|
||||||
|
// A sleeve inflated by 0.25 runs from -0.25 to 12.25.
|
||||||
|
float[][] sleeve = {
|
||||||
|
{ 2.25f, -0.25f, -2.25f, 48, 36 },
|
||||||
|
{ -2.25f, -0.25f, -2.25f, 44, 36 },
|
||||||
|
{ -2.25f, 12.25f, -2.25f, 44, 48 },
|
||||||
|
{ 2.25f, 12.25f, -2.25f, 48, 48 },
|
||||||
|
};
|
||||||
|
List<float[][]> strips = QuadSplitter.split(sleeve, CUTS);
|
||||||
|
assertEquals(12, strips.size());
|
||||||
|
assertEquals(-0.25f, strips.get(0)[0][1], 1e-5);
|
||||||
|
assertEquals(1, strips.get(0)[3][1], 1e-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,149 @@
|
|||||||
|
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 keepsTheTorsoBendAndGivesItTheWholeBodysBend() throws IOException {
|
||||||
|
// A positive torso bend at axis 0 leans forward in both, so it keeps its sign (Get the Griddy
|
||||||
|
// leans forward with a bend of +0.4).
|
||||||
|
Emote torso = EmotecraftLoader.load("torso",
|
||||||
|
"{\"version\": 3, \"emote\": {\"degrees\": false, \"moves\": [{\"tick\": 0, \"torso\": {\"bend\": 0.4}}]}}");
|
||||||
|
assertEquals(0.4f, bone(torso, Bone.BODY, 0).bend(), 1e-6);
|
||||||
|
|
||||||
|
// Emotecraft's whole-body bend bends the torso; the whole player can't bend.
|
||||||
|
Emote body = inline("{\"tick\": 0, \"body\": {\"bend\": 0.4, \"axis\": 0.2, \"pitch\": 0.1}}", "\"degrees\": false, ");
|
||||||
|
assertEquals(0.4f, bone(body, Bone.BODY, 0).bend(), 1e-6);
|
||||||
|
assertEquals(0.2f, bone(body, Bone.BODY, 0).bendAxis(), 1e-6);
|
||||||
|
assertEquals(0, bone(body, Bone.ROOT, 0).bend(), 1e-6);
|
||||||
|
assertEquals(-0.1f, bone(body, Bone.ROOT, 0).pitch(), 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -9,6 +9,8 @@ pluginManagement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
|
// Downloads the Java toolchain a version needs (Java 25 for 26.1+) when it isn't installed.
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
id("dev.kikugie.stonecutter") version "0.9.8"
|
id("dev.kikugie.stonecutter") version "0.9.8"
|
||||||
// Picks the right Loom variant per Minecraft version (remapping for 1.21.x, plain for 26.1+).
|
// Picks the right Loom variant per Minecraft version (remapping for 1.21.x, plain for 26.1+).
|
||||||
id("dev.kikugie.loom-back-compat") version "0.4.2"
|
id("dev.kikugie.loom-back-compat") version "0.4.2"
|
||||||
@@ -17,7 +19,7 @@ plugins {
|
|||||||
stonecutter {
|
stonecutter {
|
||||||
create(rootProject) {
|
create(rootProject) {
|
||||||
// Same range as Saturn Client. Per-version dependencies live in stonecutter.properties.toml.
|
// Same range as Saturn Client. Per-version dependencies live in stonecutter.properties.toml.
|
||||||
versions("1.21.4", "1.21.5", "1.21.6", "1.21.7", "1.21.8", "1.21.9", "1.21.10", "1.21.11")
|
versions("1.21.4", "1.21.5", "1.21.6", "1.21.7", "1.21.8", "1.21.9", "1.21.10", "1.21.11", "26.1", "26.2", "26.3")
|
||||||
vcsVersion = "1.21.11"
|
vcsVersion = "1.21.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package org.saturnclient.emotes.fabric;
|
||||||
|
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
import org.saturnclient.emotes.core.bend.LimbBend;
|
||||||
|
import org.saturnclient.emotes.core.bend.Vec3;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
|
||||||
|
/** Pose stack helpers for attaching things to bent limbs. */
|
||||||
|
public final class BendTransforms {
|
||||||
|
private BendTransforms() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves whatever is drawn next from the straight limb onto the bent one, as if it were fixed to the
|
||||||
|
* limb at {@code y} (model pixels, in the limb's space). This is the same rigid move the ring at
|
||||||
|
* {@code y} makes, so anything attached there stays attached the way the mesh does. The pose stack
|
||||||
|
* must already be in the limb's space, i.e. after {@code ModelPart.translateAndRotate}.
|
||||||
|
*/
|
||||||
|
public static void attachAt(PoseStack poseStack, LimbBend bend, float y) {
|
||||||
|
float[] center = new float[3];
|
||||||
|
bend.bendPoint(0, y, 0, center);
|
||||||
|
Vec3 axis = bend.axis();
|
||||||
|
poseStack.translate(center[0] / 16, center[1] / 16, center[2] / 16);
|
||||||
|
Quaternionf rotation = new Quaternionf().rotationAxis(bend.angleAt(y), axis.x(), axis.y(), axis.z());
|
||||||
|
//? if >=26.3 {
|
||||||
|
/*poseStack.rotate(rotation);
|
||||||
|
*///?} else
|
||||||
|
poseStack.mulPose(rotation);
|
||||||
|
poseStack.translate(0, -y / 16, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package org.saturnclient.emotes.fabric;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.joml.Matrix4f;
|
||||||
|
import org.joml.Vector3f;
|
||||||
|
import org.saturnclient.emotes.core.bend.LimbBend;
|
||||||
|
import org.saturnclient.emotes.core.bend.QuadSplitter;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||||
|
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a part's cubes bent. Each vanilla face is cut into strips at the bend's rings (UVs split with
|
||||||
|
* it), and every vertex is moved onto the bent limb. Vertices that were shared stay shared, so the
|
||||||
|
* mesh has no gaps, and the texture is the same as vanilla apart from the cuts.
|
||||||
|
*/
|
||||||
|
public final class BentCubeRenderer {
|
||||||
|
private BentCubeRenderer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void render(List<ModelPart.Cube> cubes, LimbBend bend, PoseStack.Pose pose,
|
||||||
|
VertexConsumer consumer, int light, int overlay, int color) {
|
||||||
|
Matrix4f matrix = pose.pose();
|
||||||
|
float[] cuts = bend.cuts();
|
||||||
|
float[] bent = new float[3];
|
||||||
|
Vector3f position = new Vector3f();
|
||||||
|
Vector3f normal = new Vector3f();
|
||||||
|
|
||||||
|
for (ModelPart.Cube cube : cubes) {
|
||||||
|
for (ModelPart.Polygon polygon : cube.polygons) {
|
||||||
|
ModelPart.Vertex[] vertices = polygon.vertices();
|
||||||
|
float[][] quad = new float[vertices.length][];
|
||||||
|
for (int i = 0; i < vertices.length; i++) {
|
||||||
|
quad[i] = vertex(vertices[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (float[][] strip : QuadSplitter.split(quad, cuts)) {
|
||||||
|
// The strip's normal turns with the ring at its middle. End caps are flat, so theirs
|
||||||
|
// turns with the end they're at.
|
||||||
|
float stripY = (strip[0][1] + strip[2][1]) / 2;
|
||||||
|
bend.bendDirection(stripY, polygon.normal().x(), polygon.normal().y(), polygon.normal().z(),
|
||||||
|
bent);
|
||||||
|
pose.transformNormal(normal.set(bent[0], bent[1], bent[2]), normal);
|
||||||
|
|
||||||
|
for (float[] vertex : strip) {
|
||||||
|
bend.bendPoint(vertex[0], vertex[1], vertex[2], bent);
|
||||||
|
matrix.transformPosition(bent[0] / 16, bent[1] / 16, bent[2] / 16, position);
|
||||||
|
consumer.addVertex(position.x(), position.y(), position.z(), color, vertex[3], vertex[4],
|
||||||
|
overlay, light, normal.x(), normal.y(), normal.z());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {x, y, z, u, v} in model pixels. */
|
||||||
|
private static float[] vertex(ModelPart.Vertex vertex) {
|
||||||
|
//? if >=1.21.9 {
|
||||||
|
return new float[] { vertex.x(), vertex.y(), vertex.z(), vertex.u(), vertex.v() };
|
||||||
|
//?} else
|
||||||
|
/*return new float[] { vertex.pos().x(), vertex.pos().y(), vertex.pos().z(), vertex.u(), vertex.v() };*/
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
|
|
||||||
import org.saturnclient.emotes.core.Animation;
|
import org.saturnclient.emotes.core.Animation;
|
||||||
import org.saturnclient.emotes.core.AnimationPlayer;
|
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.saturnclient.emotes.core.Pose;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -17,9 +19,10 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
|||||||
/** Client-side entry point: which player is playing which animation. */
|
/** Client-side entry point: which player is playing which animation. */
|
||||||
public final class Emotes implements ClientModInitializer {
|
public final class Emotes implements ClientModInitializer {
|
||||||
public static final String MOD_ID = "saturn_emotes";
|
public static final String MOD_ID = "saturn_emotes";
|
||||||
public static final String VERSION = /*$ mod_version*/ "0.1.0";
|
public static final String VERSION = /*$ mod_version*/ "0.1.2";
|
||||||
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
|
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 final Map<UUID, AnimationPlayer> PLAYING = new ConcurrentHashMap<>();
|
||||||
private static long tick;
|
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. */
|
/** Starts {@code animation} on the player, replacing whatever they were playing. */
|
||||||
public static void play(UUID player, Animation animation) {
|
public static void play(UUID player, Animation animation) {
|
||||||
PLAYING.put(player, new AnimationPlayer(animation, tick));
|
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) {
|
public static void stop(UUID player) {
|
||||||
PLAYING.remove(player);
|
PLAYING.remove(player);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package org.saturnclient.emotes.fabric;
|
package org.saturnclient.emotes.fabric;
|
||||||
|
|
||||||
|
import org.joml.Quaternionf;
|
||||||
|
import org.joml.Vector3f;
|
||||||
import org.saturnclient.emotes.core.Bone;
|
import org.saturnclient.emotes.core.Bone;
|
||||||
import org.saturnclient.emotes.core.BoneTransform;
|
import org.saturnclient.emotes.core.BoneTransform;
|
||||||
import org.saturnclient.emotes.core.Pose;
|
import org.saturnclient.emotes.core.Pose;
|
||||||
|
import org.saturnclient.emotes.core.bend.LimbBend;
|
||||||
|
import org.saturnclient.emotes.core.bend.Vec3;
|
||||||
|
import org.saturnclient.emotes.fabric.access.BendablePart;
|
||||||
|
|
||||||
import net.minecraft.client.model.HumanoidModel;
|
import net.minecraft.client.model.HumanoidModel;
|
||||||
import net.minecraft.client.model.geom.ModelPart;
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
@@ -12,17 +17,47 @@ import net.minecraft.client.model.geom.ModelPart;
|
|||||||
* jacket, pants) are children of the base parts, so they follow automatically.
|
* jacket, pants) are children of the base parts, so they follow automatically.
|
||||||
*/
|
*/
|
||||||
public final class PoseApplier {
|
public final class PoseApplier {
|
||||||
|
/** How tightly joints curve, from 0 (round) to 1 (tight). See {@link LimbBend}. */
|
||||||
|
private static final float SHARPNESS = 0.5f;
|
||||||
|
|
||||||
private 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) {
|
public static void apply(HumanoidModel<?> model, Pose pose) {
|
||||||
for (var entry : pose.bones().entrySet()) {
|
pose.get(Bone.ROOT).ifPresent(transform -> applyRoot(model.root(), transform));
|
||||||
apply(part(model, entry.getKey()), entry.getValue());
|
|
||||||
|
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) {
|
||||||
|
apply(part, transform);
|
||||||
|
}
|
||||||
|
// Models are shared between entities, so bends from the last one drawn must be cleared.
|
||||||
|
LimbBend bend = bend(bone, transform);
|
||||||
|
((BendablePart) (Object) part).saturnEmotes$setBend(bend);
|
||||||
|
if (bone == Bone.BODY) {
|
||||||
|
bodyBend = bend;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The torso bends from the hips, so whatever hangs off its upper half moves with it.
|
||||||
|
if (bodyBend != null && !bodyBend.isStraight()) {
|
||||||
|
carry(model.body, bodyBend, model.head);
|
||||||
|
carry(model.body, bodyBend, model.rightArm);
|
||||||
|
carry(model.body, bodyBend, model.leftArm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ModelPart part(HumanoidModel<?> model, Bone bone) {
|
private static ModelPart part(HumanoidModel<?> model, Bone bone) {
|
||||||
return switch (bone) {
|
return switch (bone) {
|
||||||
|
case ROOT -> model.root();
|
||||||
case HEAD -> model.head;
|
case HEAD -> model.head;
|
||||||
case BODY -> model.body;
|
case BODY -> model.body;
|
||||||
case RIGHT_ARM -> model.rightArm;
|
case RIGHT_ARM -> model.rightArm;
|
||||||
@@ -40,6 +75,72 @@ public final class PoseApplier {
|
|||||||
part.xRot = transform.pitch();
|
part.xRot = transform.pitch();
|
||||||
part.yRot = transform.yaw();
|
part.yRot = transform.yaw();
|
||||||
part.zRot = transform.roll();
|
part.zRot = transform.roll();
|
||||||
// TODO: bend (transform.bend(), transform.bendAxis()) needs a custom mesh.
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
// Vanilla player limbs, in each part's own space. Armor uses the same ranges so it bends with
|
||||||
|
// the limb instead of along its own (inflated) box. One segment per texture row, so each piece
|
||||||
|
// keeps a whole row of pixels.
|
||||||
|
float bend = transform.bend();
|
||||||
|
float axis = transform.bendAxis();
|
||||||
|
return switch (bone) {
|
||||||
|
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.
|
||||||
|
case BODY -> LimbBend.anchoredAtEnd(0, 12, bend, axis, SHARPNESS, 12);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves {@code part} (a sibling of the body in the model) as if it were attached to the bent torso
|
||||||
|
* at its pivot: its pivot follows the torso and it turns with the torso's ring there.
|
||||||
|
*/
|
||||||
|
private static void carry(ModelPart body, LimbBend bend, ModelPart part) {
|
||||||
|
Quaternionf bodyRotation = rotation(body);
|
||||||
|
|
||||||
|
// The part's pivot in the torso's own space.
|
||||||
|
Vector3f pivot = bodyRotation.transformInverse(
|
||||||
|
new Vector3f(part.x - body.x, part.y - body.y, part.z - body.z));
|
||||||
|
float[] bent = new float[3];
|
||||||
|
bend.bendPoint(pivot.x, pivot.y, pivot.z, bent);
|
||||||
|
Vector3f moved = bodyRotation.transform(new Vector3f(bent[0], bent[1], bent[2]));
|
||||||
|
part.x = body.x + moved.x;
|
||||||
|
part.y = body.y + moved.y;
|
||||||
|
part.z = body.z + moved.z;
|
||||||
|
|
||||||
|
// The ring's turn happens in the torso's space, so bring it into model space around the part's
|
||||||
|
// own rotation.
|
||||||
|
Vec3 axis = bend.axis();
|
||||||
|
Quaternionf turn = new Quaternionf().rotationAxis(bend.angleAt(pivot.y), axis.x(), axis.y(), axis.z());
|
||||||
|
Quaternionf result = new Quaternionf(bodyRotation).mul(turn).mul(new Quaternionf(bodyRotation).conjugate())
|
||||||
|
.mul(rotation(part));
|
||||||
|
Vector3f euler = result.getEulerAnglesZYX(new Vector3f());
|
||||||
|
part.xRot = euler.x;
|
||||||
|
part.yRot = euler.y;
|
||||||
|
part.zRot = euler.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rotation ModelPart.translateAndRotate applies. */
|
||||||
|
private static Quaternionf rotation(ModelPart part) {
|
||||||
|
return new Quaternionf().rotationZYX(part.zRot, part.yRot, part.xRot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.saturnclient.emotes.fabric.access;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.saturnclient.emotes.core.bend.LimbBend;
|
||||||
|
|
||||||
|
/** Mixed into ModelPart. A part with a bend draws its cubes bent instead of rigid. */
|
||||||
|
public interface BendablePart {
|
||||||
|
@Nullable
|
||||||
|
LimbBend saturnEmotes$getBend();
|
||||||
|
|
||||||
|
/** Sets the bend on this part and all its children (overlay layers like sleeves). */
|
||||||
|
void saturnEmotes$setBend(@Nullable LimbBend bend);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.saturnclient.emotes.fabric.mixin;
|
||||||
|
|
||||||
|
import org.saturnclient.emotes.fabric.access.BendablePart;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
import com.llamalad7.mixinextras.sugar.Local;
|
||||||
|
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
//? if >=1.21.9 {
|
||||||
|
import net.minecraft.client.renderer.entity.player.AvatarRenderer;
|
||||||
|
//?} else
|
||||||
|
/*import net.minecraft.client.renderer.entity.player.PlayerRenderer;*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first-person arm is drawn from the same shared model without calling setupAnim, so it would
|
||||||
|
* keep whatever bend the last third-person player drawn left on it. It should never be bent.
|
||||||
|
*/
|
||||||
|
//? if >=1.21.9 {
|
||||||
|
@Mixin(AvatarRenderer.class)
|
||||||
|
//?} else
|
||||||
|
/*@Mixin(PlayerRenderer.class)*/
|
||||||
|
public class FirstPersonHandMixin {
|
||||||
|
@Inject(method = "renderHand", at = @At("HEAD"))
|
||||||
|
private void saturnEmotes$straightenArm(CallbackInfo ci, @Local(argsOnly = true) ModelPart arm) {
|
||||||
|
((BendablePart) (Object) arm).saturnEmotes$setBend(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.saturnclient.emotes.fabric.mixin;
|
package org.saturnclient.emotes.fabric.mixin;
|
||||||
|
|
||||||
import org.saturnclient.emotes.core.Pose;
|
|
||||||
import org.saturnclient.emotes.fabric.PoseApplier;
|
import org.saturnclient.emotes.fabric.PoseApplier;
|
||||||
import org.saturnclient.emotes.fabric.access.EmotePoseHolder;
|
import org.saturnclient.emotes.fabric.access.EmotePoseHolder;
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
@@ -21,9 +20,7 @@ public class HumanoidModelMixin {
|
|||||||
method = "setupAnim(Lnet/minecraft/client/renderer/entity/state/HumanoidRenderState;)V",
|
method = "setupAnim(Lnet/minecraft/client/renderer/entity/state/HumanoidRenderState;)V",
|
||||||
at = @At("TAIL"))
|
at = @At("TAIL"))
|
||||||
private void saturnEmotes$applyPose(HumanoidRenderState state, CallbackInfo ci) {
|
private void saturnEmotes$applyPose(HumanoidRenderState state, CallbackInfo ci) {
|
||||||
Pose pose = ((EmotePoseHolder) state).saturnEmotes$getPose();
|
// Always applied, even for an empty pose, so bends left over from another entity get cleared.
|
||||||
if (!pose.isEmpty()) {
|
PoseApplier.apply((HumanoidModel<?>) (Object) this, ((EmotePoseHolder) state).saturnEmotes$getPose());
|
||||||
PoseApplier.apply((HumanoidModel<?>) (Object) this, pose);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package org.saturnclient.emotes.fabric.mixin;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
import org.saturnclient.emotes.core.bend.LimbBend;
|
||||||
|
import org.saturnclient.emotes.fabric.BentCubeRenderer;
|
||||||
|
import org.saturnclient.emotes.fabric.access.BendablePart;
|
||||||
|
import org.spongepowered.asm.mixin.Final;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
|
import org.spongepowered.asm.mixin.Unique;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.mojang.blaze3d.vertex.VertexConsumer;
|
||||||
|
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
|
||||||
|
@Mixin(ModelPart.class)
|
||||||
|
public class ModelPartMixin implements BendablePart {
|
||||||
|
@Shadow
|
||||||
|
@Final
|
||||||
|
private List<ModelPart.Cube> cubes;
|
||||||
|
|
||||||
|
@Shadow
|
||||||
|
@Final
|
||||||
|
private Map<String, ModelPart> children;
|
||||||
|
|
||||||
|
@Unique
|
||||||
|
private @Nullable LimbBend saturnEmotes$bend;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable LimbBend saturnEmotes$getBend() {
|
||||||
|
return saturnEmotes$bend;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saturnEmotes$setBend(@Nullable LimbBend bend) {
|
||||||
|
if (bend == null && saturnEmotes$bend == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saturnEmotes$bend = bend;
|
||||||
|
for (ModelPart child : children.values()) {
|
||||||
|
((BendablePart) (Object) child).saturnEmotes$setBend(bend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(method = "compile", at = @At("HEAD"), cancellable = true)
|
||||||
|
private void saturnEmotes$compileBent(PoseStack.Pose pose, VertexConsumer consumer, int light, int overlay,
|
||||||
|
int color, CallbackInfo ci) {
|
||||||
|
if (saturnEmotes$bend != null && !saturnEmotes$bend.isStraight()) {
|
||||||
|
BentCubeRenderer.render(cubes, saturnEmotes$bend, pose, consumer, light, overlay, color);
|
||||||
|
ci.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package org.saturnclient.emotes.fabric.mixin;
|
||||||
|
|
||||||
|
import org.saturnclient.emotes.core.bend.LimbBend;
|
||||||
|
import org.saturnclient.emotes.fabric.BendTransforms;
|
||||||
|
import org.saturnclient.emotes.fabric.access.BendablePart;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Unique;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
import com.llamalad7.mixinextras.sugar.Local;
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
|
||||||
|
import net.minecraft.client.model.HumanoidModel;
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
import net.minecraft.world.entity.HumanoidArm;
|
||||||
|
//? if >=1.21.11 {
|
||||||
|
import net.minecraft.client.model.player.PlayerModel;
|
||||||
|
//?} else
|
||||||
|
/*import net.minecraft.client.model.PlayerModel;*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Held items are placed at the hand of the straight arm. PlayerModel overrides translateToHand (for
|
||||||
|
* slim arms) without calling HumanoidModel's, so this has to hook PlayerModel's own.
|
||||||
|
*/
|
||||||
|
@Mixin(PlayerModel.class)
|
||||||
|
public class PlayerModelMixin {
|
||||||
|
//? if >=1.21.9 {
|
||||||
|
@Unique
|
||||||
|
private static final String TRANSLATE_TO_HAND = "translateToHand(Lnet/minecraft/client/renderer/entity/state/AvatarRenderState;Lnet/minecraft/world/entity/HumanoidArm;Lcom/mojang/blaze3d/vertex/PoseStack;)V";
|
||||||
|
//?} else
|
||||||
|
/*@Unique private static final String TRANSLATE_TO_HAND = "translateToHand(Lnet/minecraft/world/entity/HumanoidArm;Lcom/mojang/blaze3d/vertex/PoseStack;)V";*/
|
||||||
|
|
||||||
|
/** Moves the item from the hand of the straight arm to the hand of the bent one. */
|
||||||
|
@Inject(method = TRANSLATE_TO_HAND, at = @At("TAIL"))
|
||||||
|
private void saturnEmotes$followBentArm(CallbackInfo ci, @Local(argsOnly = true) HumanoidArm arm,
|
||||||
|
@Local(argsOnly = true) PoseStack poseStack) {
|
||||||
|
HumanoidModel<?> model = (HumanoidModel<?>) (Object) this;
|
||||||
|
ModelPart part = arm == HumanoidArm.LEFT ? model.leftArm : model.rightArm;
|
||||||
|
LimbBend bend = ((BendablePart) (Object) part).saturnEmotes$getBend();
|
||||||
|
if (bend != null && !bend.isStraight()) {
|
||||||
|
BendTransforms.attachAt(poseStack, bend, bend.end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,12 @@
|
|||||||
"package": "org.saturnclient.emotes.fabric.mixin",
|
"package": "org.saturnclient.emotes.fabric.mixin",
|
||||||
"compatibilityLevel": "${java}",
|
"compatibilityLevel": "${java}",
|
||||||
"client": [
|
"client": [
|
||||||
|
"FirstPersonHandMixin",
|
||||||
"HumanoidModelMixin",
|
"HumanoidModelMixin",
|
||||||
"LivingEntityRenderStateMixin",
|
"LivingEntityRenderStateMixin",
|
||||||
"LivingEntityRendererMixin"
|
"LivingEntityRendererMixin",
|
||||||
|
"ModelPartMixin",
|
||||||
|
"PlayerModelMixin"
|
||||||
],
|
],
|
||||||
"injectors": {
|
"injectors": {
|
||||||
"defaultRequire": 1
|
"defaultRequire": 1
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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;
|
||||||
|
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
|
||||||
|
import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext;
|
||||||
|
import net.minecraft.client.CameraType;
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screenshots each test animation at set points, from the front, the side and behind.
|
||||||
|
* Run with {@code ./gradlew :<mc>:runClientGameTest}. Screenshots land in
|
||||||
|
* {@code versions/<mc>/build/run/clientGameTest/screenshots/}.
|
||||||
|
*/
|
||||||
|
public final class EmotesGameTest implements FabricClientGameTest {
|
||||||
|
private static final String MINECRAFT = /*$ minecraft*/ "1.21.11";
|
||||||
|
|
||||||
|
/** What to play, the tick to screenshot it at, and the angles to look from (0 is the front). */
|
||||||
|
private static final Shot[] SHOTS = {
|
||||||
|
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.
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
public void runTest(ClientGameTestContext context) {
|
||||||
|
try (TestSingleplayerContext singleplayer = context.worldBuilder().create()) {
|
||||||
|
//? if >=26.2 {
|
||||||
|
/*singleplayer.getConnection().waitForChunksRender();
|
||||||
|
*///?} else if >=26.1 {
|
||||||
|
/*singleplayer.getClientLevel().waitForChunksRender();
|
||||||
|
*///?} else
|
||||||
|
singleplayer.getClientWorld().waitForChunksRender();
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
setHudHidden(client, true);
|
||||||
|
client.options.setCameraType(CameraType.THIRD_PERSON_FRONT);
|
||||||
|
});
|
||||||
|
context.waitTicks(20);
|
||||||
|
|
||||||
|
for (Shot shot : SHOTS) {
|
||||||
|
context.runOnClient(client -> Emotes.play(client.player.getUUID(),
|
||||||
|
shot.registry().get(shot.id()).orElseThrow().animation()));
|
||||||
|
context.waitTicks(shot.tick());
|
||||||
|
for (float angle : shot.angles()) {
|
||||||
|
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.REGISTRY.get("hold").orElseThrow().animation()));
|
||||||
|
context.waitTicks(10);
|
||||||
|
screenshot(context, "hold-0", 0);
|
||||||
|
screenshot(context, "hold-90", 90);
|
||||||
|
|
||||||
|
// The first-person arm shares the model, so it must not keep the bend from the frame above.
|
||||||
|
// Vanilla only draws the arm with an empty hand, and hiding the GUI hides it too.
|
||||||
|
singleplayer.getServer().runCommand("clear @a");
|
||||||
|
context.waitTicks(2);
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
setHudHidden(client, false);
|
||||||
|
client.options.setCameraType(CameraType.FIRST_PERSON);
|
||||||
|
});
|
||||||
|
// Give the hand time to come back up after the item change.
|
||||||
|
context.waitTicks(20);
|
||||||
|
context.takeScreenshot(MINECRAFT + "-hold-firstperson");
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
Emotes.stop(client.player.getUUID());
|
||||||
|
setHudHidden(client, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setHudHidden(Minecraft client, boolean hidden) {
|
||||||
|
// From 26.2 the HUD can only be toggled.
|
||||||
|
//? if >=26.2 {
|
||||||
|
/*if (client.gui.hud.isHidden() != hidden) {
|
||||||
|
client.gui.hud.toggle();
|
||||||
|
}
|
||||||
|
*///?} else
|
||||||
|
client.options.hideGui = hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns the body (not the camera) by {@code turn} degrees, then takes a screenshot. */
|
||||||
|
private static void screenshot(ClientGameTestContext context, String name, float turn) {
|
||||||
|
context.runOnClient(client -> {
|
||||||
|
float body = client.player.getYRot() + turn;
|
||||||
|
client.player.setYBodyRot(body);
|
||||||
|
client.player.yBodyRotO = body;
|
||||||
|
});
|
||||||
|
context.takeScreenshot(MINECRAFT + "-" + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
package org.saturnclient.emotes.testmod;
|
package org.saturnclient.emotes.testmod;
|
||||||
|
|
||||||
|
//? if >=26.1 {
|
||||||
|
/*import static net.fabricmc.fabric.api.client.command.v2.ClientCommands.argument;
|
||||||
|
import static net.fabricmc.fabric.api.client.command.v2.ClientCommands.literal;
|
||||||
|
*///?} else {
|
||||||
import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument;
|
import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument;
|
||||||
import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal;
|
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.saturnclient.emotes.fabric.Emotes;
|
||||||
import org.spongepowered.asm.mixin.MixinEnvironment;
|
import org.spongepowered.asm.mixin.MixinEnvironment;
|
||||||
|
|
||||||
@@ -18,46 +27,70 @@ import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
|
|||||||
import net.minecraft.network.chat.Component;
|
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.
|
* Switch to third person (F5) to see yourself.
|
||||||
*/
|
*/
|
||||||
public final class EmotesTestmod implements ClientModInitializer {
|
public final class EmotesTestmod implements ClientModInitializer {
|
||||||
private static final String MINECRAFT = /*$ minecraft*/ "1.21.11";
|
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
|
@Override
|
||||||
public void onInitializeClient() {
|
public void onInitializeClient() {
|
||||||
// Applies every mixin now, so a broken target crashes at startup instead of in a world.
|
// Applies every mixin now, so a broken target crashes at startup instead of in a world.
|
||||||
MixinEnvironment.getCurrentEnvironment().audit();
|
MixinEnvironment.getCurrentEnvironment().audit();
|
||||||
Emotes.LOGGER.info("Mixin audit passed on Minecraft {}", MINECRAFT);
|
Emotes.LOGGER.info("Mixin audit passed on Minecraft {}", MINECRAFT);
|
||||||
|
loadEmotes();
|
||||||
|
|
||||||
ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(
|
ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(
|
||||||
literal("emotes")
|
literal("emotes")
|
||||||
.then(literal("play").then(argument("name", StringArgumentType.word())
|
.then(literal("play").then(argument("id", StringArgumentType.word())
|
||||||
.suggests((ctx, builder) -> {
|
.suggests((ctx, builder) -> {
|
||||||
TestAnimations.ALL.keySet().forEach(builder::suggest);
|
Emotes.registry().ids().forEach(builder::suggest);
|
||||||
return builder.buildFuture();
|
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 -> {
|
.then(literal("stop").executes(ctx -> {
|
||||||
Emotes.stop(ctx.getSource().getPlayer().getUUID());
|
Emotes.stop(ctx.getSource().getPlayer().getUUID());
|
||||||
return 1;
|
return 1;
|
||||||
}))
|
}))
|
||||||
.then(literal("info").executes(ctx -> {
|
.then(literal("info").executes(ctx -> {
|
||||||
ctx.getSource().sendFeedback(Component.literal("Saturn Emotes " + Emotes.VERSION
|
ctx.getSource().sendFeedback(Component.literal("Saturn Emotes " + Emotes.VERSION
|
||||||
+ " on Minecraft " + MINECRAFT + ". Animations: "
|
+ " on Minecraft " + MINECRAFT + ". Emotes: "
|
||||||
+ String.join(", ", new TreeSet<>(TestAnimations.ALL.keySet()))));
|
+ String.join(", ", Emotes.registry().ids()) + ". Test animations: "
|
||||||
|
+ String.join(", ", TestAnimations.REGISTRY.ids())));
|
||||||
return 1;
|
return 1;
|
||||||
}))));
|
}))));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int play(CommandContext<FabricClientCommandSource> ctx) {
|
private static void loadEmotes() {
|
||||||
String name = StringArgumentType.getString(ctx, "name");
|
for (String id : EMOTECRAFT_EMOTES) {
|
||||||
Animation animation = TestAnimations.ALL.get(name);
|
String path = "/assets/saturn_emotes_testmod/player_animations/" + id + ".json";
|
||||||
if (animation == null) {
|
try (InputStream in = EmotesTestmod.class.getResourceAsStream(path)) {
|
||||||
ctx.getSource().sendError(Component.literal("Unknown animation: " + name));
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
Emotes.play(ctx.getSource().getPlayer().getUUID(), animation);
|
Emotes.play(ctx.getSource().getPlayer().getUUID(), emote.get().animation());
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
package org.saturnclient.emotes.testmod;
|
package org.saturnclient.emotes.testmod;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
|
||||||
import org.saturnclient.emotes.core.Animation;
|
import org.saturnclient.emotes.core.Animation;
|
||||||
import org.saturnclient.emotes.core.Bone;
|
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.BoneTransform;
|
||||||
import org.saturnclient.emotes.core.Pose;
|
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 {
|
final class TestAnimations {
|
||||||
private static final float HALF_PI = (float) (Math.PI / 2);
|
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.
|
// Right arm raised, waving side to side. Tests rotation.
|
||||||
"wave", of(40, true, t -> Pose.builder()
|
"wave", of(40, true, t -> Pose.builder()
|
||||||
.set(Bone.RIGHT_ARM, BoneTransform.rotation((float) Math.PI, 0,
|
.set(Bone.RIGHT_ARM, BoneTransform.rotation((float) Math.PI, 0,
|
||||||
@@ -33,18 +39,108 @@ final class TestAnimations {
|
|||||||
.set(Bone.LEFT_ARM, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
|
.set(Bone.LEFT_ARM, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
|
||||||
.build();
|
.build();
|
||||||
}),
|
}),
|
||||||
// Arms forward with elbows folding in and out. Bend isn't rendered yet.
|
// Arms forward with elbows folding in and out. Tests bending towards the front.
|
||||||
"bend", of(40, true, t -> {
|
"bend", of(40, true, t -> {
|
||||||
float bend = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI));
|
float bend = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI));
|
||||||
return Pose.builder()
|
return Pose.builder()
|
||||||
.set(Bone.RIGHT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0))
|
.set(Bone.RIGHT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0))
|
||||||
.set(Bone.LEFT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0))
|
.set(Bone.LEFT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0))
|
||||||
.build();
|
.build();
|
||||||
}));
|
}),
|
||||||
|
// Arms out to the sides with elbows folding down. Tests the bend axis.
|
||||||
|
"flap", of(30, true, t -> {
|
||||||
|
float bend = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 30f * 2 * Math.PI));
|
||||||
|
return Pose.builder()
|
||||||
|
.set(Bone.RIGHT_ARM, BoneTransform.rotation(0, 0, HALF_PI).withBend(bend, HALF_PI))
|
||||||
|
.set(Bone.LEFT_ARM, BoneTransform.rotation(0, 0, -HALF_PI).withBend(bend, -HALF_PI))
|
||||||
|
.build();
|
||||||
|
}),
|
||||||
|
// Thighs forward and knees folding back, past 90°. Tests legs, pants and sharp bends.
|
||||||
|
"squat", of(40, true, t -> {
|
||||||
|
float depth = 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI));
|
||||||
|
BoneTransform leg = BoneTransform.rotation(-1.2f * depth, 0, 0)
|
||||||
|
.withBend(2.2f * depth, (float) Math.PI);
|
||||||
|
return Pose.builder()
|
||||||
|
.set(Bone.RIGHT_LEG, leg)
|
||||||
|
.set(Bone.LEFT_LEG, leg)
|
||||||
|
.build();
|
||||||
|
}),
|
||||||
|
// Chest leaning forward from the hips, head and arms carried with it. Tests the torso.
|
||||||
|
"bow", of(40, true, t -> {
|
||||||
|
float lean = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI));
|
||||||
|
return Pose.builder()
|
||||||
|
.set(Bone.BODY, BoneTransform.IDENTITY.withBend(lean, 0))
|
||||||
|
.build();
|
||||||
|
}),
|
||||||
|
// Right arm forward with the forearm folded up, as if showing off a sword. Tests held items.
|
||||||
|
"hold", of(20, true, t -> Pose.builder()
|
||||||
|
.set(Bone.RIGHT_ARM, BoneTransform.rotation(-0.9f, 0, 0).withBend(1.4f, 0))
|
||||||
|
.build()),
|
||||||
|
"twerk", of(8, true, TestAnimations::twerk));
|
||||||
|
|
||||||
private 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
|
||||||
|
* hunched back) and the chest comes up. The arch deepens at the top of each bounce.
|
||||||
|
*/
|
||||||
|
private static Pose twerk(float t) {
|
||||||
|
// 0 at the bottom of the bounce, 1 at the top.
|
||||||
|
float up = 0.5f * (1 - (float) Math.cos(t / 8f * 2 * Math.PI));
|
||||||
|
|
||||||
|
float thighPitch = -1.1f + 0.15f * up;
|
||||||
|
float knee = 1.75f - 0.3f * up;
|
||||||
|
float splay = 0.55f;
|
||||||
|
// How far the torso tips forward at the hips, and how much of that the arch takes back by the
|
||||||
|
// chest. The chest ends up leaning forward by tilt - arch.
|
||||||
|
float tilt = 1.4f;
|
||||||
|
float arch = 0.4f + 0.3f * up;
|
||||||
|
|
||||||
|
// Lower everything by as much as the bent legs got shorter, and push the hips back so the feet
|
||||||
|
// stay under where they were (a rigid thigh + shin approximation of the curved leg).
|
||||||
|
float drop = 12 - 6 * (float) (Math.cos(thighPitch) + Math.cos(thighPitch + knee)) * (float) Math.cos(splay);
|
||||||
|
float back = -6 * (float) (Math.sin(thighPitch) + Math.sin(thighPitch + knee));
|
||||||
|
|
||||||
|
// The body pivots at the neck, so tipping it forward swings the hips back. Move the pivot so
|
||||||
|
// the hips stay on the legs.
|
||||||
|
float cos = (float) Math.cos(tilt);
|
||||||
|
float sin = (float) Math.sin(tilt);
|
||||||
|
float bodyY = drop + 12 - 12 * cos;
|
||||||
|
float bodyZ = back - 12 * sin;
|
||||||
|
|
||||||
|
// The head and arms only turn with the torso's bend, not its rotation, so they get the tilt
|
||||||
|
// themselves (tilt plus the bend's turn is the chest's lean) and sit where the tipped torso puts
|
||||||
|
// them: the neck at the body's pivot, the shoulders 2 pixels down it.
|
||||||
|
float chest = tilt - arch;
|
||||||
|
float shoulderY = bodyY + 2 * cos - 2;
|
||||||
|
float shoulderZ = bodyZ + 2 * sin;
|
||||||
|
// Arms riding on a leaning chest would swing back, so tilt them forward to hang to the knees.
|
||||||
|
float armPitch = tilt - chest * 1.1f;
|
||||||
|
// Follow the chest, then look up by half its lean.
|
||||||
|
float headPitch = tilt - chest * 0.5f;
|
||||||
|
|
||||||
|
return Pose.builder()
|
||||||
|
.set(Bone.BODY, new BoneTransform(0, bodyY, bodyZ, tilt, 0, 0, arch, (float) Math.PI))
|
||||||
|
.set(Bone.HEAD, new BoneTransform(0, bodyY, bodyZ, headPitch, 0, 0, 0, 0))
|
||||||
|
.set(Bone.RIGHT_ARM, new BoneTransform(0, shoulderY, shoulderZ, armPitch, 0, 0.3f, 0.5f, 0))
|
||||||
|
.set(Bone.LEFT_ARM, new BoneTransform(0, shoulderY, shoulderZ, armPitch, 0, -0.3f, 0.5f, 0))
|
||||||
|
// Knees fold back and a little inwards, so the thighs splay out and the feet come in.
|
||||||
|
.set(Bone.RIGHT_LEG, new BoneTransform(0, drop, back, thighPitch, 0, splay, knee,
|
||||||
|
(float) Math.PI - 0.35f))
|
||||||
|
.set(Bone.LEFT_LEG, new BoneTransform(0, drop, back, thighPitch, 0, -splay, knee,
|
||||||
|
(float) Math.PI + 0.35f))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
private static Animation of(float length, boolean loops, Function<Float, Pose> sampler) {
|
private static Animation of(float length, boolean loops, Function<Float, Pose> sampler) {
|
||||||
return new Animation() {
|
return new Animation() {
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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,7 +7,8 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"environment": "client",
|
"environment": "client",
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"client": ["org.saturnclient.emotes.testmod.EmotesTestmod"]
|
"client": ["org.saturnclient.emotes.testmod.EmotesTestmod"],
|
||||||
|
"fabric-client-gametest": ["org.saturnclient.emotes.testmod.EmotesGameTest"]
|
||||||
},
|
},
|
||||||
"depends": {
|
"depends": {
|
||||||
"saturn_emotes": "*",
|
"saturn_emotes": "*",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
mod.id = "saturn_emotes"
|
mod.id = "saturn_emotes"
|
||||||
mod.name = "Saturn Emotes"
|
mod.name = "Saturn Emotes"
|
||||||
mod.group = "org.saturnclient"
|
mod.group = "org.saturnclient"
|
||||||
mod.version = "0.1.0"
|
mod.version = "0.1.2"
|
||||||
|
|
||||||
deps.fabric_loader = "0.19.5"
|
deps.fabric_loader = "0.19.5"
|
||||||
loomx.loom_version = "1.17.21"
|
loomx.loom_version = "1.17.21"
|
||||||
@@ -38,3 +38,16 @@ deps.fabric_api = "0.138.4+1.21.10"
|
|||||||
["1.21.11"]
|
["1.21.11"]
|
||||||
mod.mc_compat = "1.21.11"
|
mod.mc_compat = "1.21.11"
|
||||||
deps.fabric_api = "0.141.6+1.21.11"
|
deps.fabric_api = "0.141.6+1.21.11"
|
||||||
|
|
||||||
|
# Built against 26.1 and allowed on its hotfixes (26.1.1, 26.1.2).
|
||||||
|
["26.1"]
|
||||||
|
mod.mc_compat = "~26.1"
|
||||||
|
deps.fabric_api = "0.145.1+26.1"
|
||||||
|
|
||||||
|
["26.2"]
|
||||||
|
mod.mc_compat = "26.2"
|
||||||
|
deps.fabric_api = "0.161.0+26.2"
|
||||||
|
|
||||||
|
["26.3"]
|
||||||
|
mod.mc_compat = "26.3"
|
||||||
|
deps.fabric_api = "0.161.0+26.3"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
# Captures, fetched skins and scratch output: large, and regenerated by the scripts.
|
||||||
|
frames/
|
||||||
|
skins/
|
||||||
|
*.log
|
||||||
|
*.png
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import org.saturnclient.emotes.core.bend.LimbBend;
|
||||||
|
|
||||||
|
/** Prints LimbBend results for check_port.py to compare with the Python port. */
|
||||||
|
public class CheckPort {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
float[][] cases = { {-2, 10, 0.5f, 0, 0}, {-2, 10, 1.75f, 3.5f, 0}, {0, 12, 1.2f, 2.79f, 0},
|
||||||
|
{0, 12, 0.7f, 3.14159f, 1}, {0, 12, -0.9f, 1.0f, 1} };
|
||||||
|
float[][] points = { {0, 0, 0}, {1.5f, 3, -2.25f}, {-2.25f, 10.25f, 2.25f}, {2, -2.25f, 1}, {0.5f, 12.25f, -1} };
|
||||||
|
float[] out = new float[3];
|
||||||
|
for (float[] c : cases) {
|
||||||
|
LimbBend b = c[4] == 1 ? LimbBend.anchoredAtEnd(c[0], c[1], c[2], c[3], 0.5f, 12)
|
||||||
|
: new LimbBend(c[0], c[1], c[2], c[3], 0.5f, 12);
|
||||||
|
for (float[] p : points) {
|
||||||
|
b.bendPoint(p[0], p[1], p[2], out);
|
||||||
|
System.out.printf("%f %f %f %f%n", out[0], out[1], out[2], b.angleAt(p[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Emote capture
|
||||||
|
|
||||||
|
Measures an emote from a store page's 3D preview and writes it as an Emotecraft emote our loader
|
||||||
|
reads. It works from pixels only: it renders our own player model in the same pose from the same
|
||||||
|
cameras and adjusts the pose until outlines and colours match. It never reads the page's animation
|
||||||
|
data.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
.venv/bin/playwright install firefox # Playwright's own Firefox, in its cache folder
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python capture.py <store url> <name> --skin Steve # frames/<name>/<view>/<tick>.png + views.json
|
||||||
|
.venv/bin/python measure.py <name> --ticks 0:13 # frames/<name>/rig.json (one loop: 13 ticks)
|
||||||
|
.venv/bin/python fit.py <name> --symmetric --title "Twerk" --out <path>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`capture.py`** opens the page in headless Firefox with `viewer.js` injected, switches the
|
||||||
|
preview to 3D and puts a skin on it. `--skin` takes one of the dialog's presets or any Minecraft
|
||||||
|
username. `viewer.js` replaces the page's clock, so frames step one tick (50 ms) at a time however
|
||||||
|
fast the emote moves. It also finds the camera through three.js's devtools hook. Each tick is taken
|
||||||
|
from 10 fixed cameras with a transparent background. It also measures the loop length from the
|
||||||
|
frames.
|
||||||
|
- **`skin.py`** fetches that username's skin from Mojang, so our model can wear the same texture.
|
||||||
|
- **`measure.py`** fits our rig to every tick. It renders the model with `model.py` (a port of
|
||||||
|
`LimbBend` and `PoseApplier`) and `render.py`, and scores it by outline distance and colour. It
|
||||||
|
searches with CMA-ES on all cores. The first tick is searched in stages: torso and head, then each
|
||||||
|
limb from several starts, then everything, then its back-to-front twins (`flip.py`). It also finds
|
||||||
|
the viewer's scale then. For a whole loop, later rounds refit every tick held near the loop's
|
||||||
|
smoothed curve, so drift can't build up. Leaning further forward while arching further back looks
|
||||||
|
almost the same from every camera, so a small cost on the torso's bend picks the straightest torso.
|
||||||
|
- **`fit.py`** smooths each channel over the loop (Fourier harmonics) and stands the emote on the
|
||||||
|
ground. It keys each channel wherever straight-line interpolation misses by more than 1° or 0.1 px.
|
||||||
|
`--symmetric` removes left/right lopsidedness (`symmetry.py`): each tick is averaged with the mirror
|
||||||
|
of the pose half a loop on, which keeps an even sway and drops any lean to one side.
|
||||||
|
|
||||||
|
## Checking
|
||||||
|
|
||||||
|
- `check_port.py` compares `model.py`'s bend maths with the Java classes (after
|
||||||
|
`./gradlew :core:compileJava`).
|
||||||
|
- `synth_test.py` renders our own twerk test animation as a fake capture, so `measure.py` can be
|
||||||
|
scored against a known answer.
|
||||||
|
- `symmetry.py <name>` lists the most lopsided channels.
|
||||||
|
- `compare.py <name> <tick>` puts the captured frames next to our render of the fit.
|
||||||
|
- `overlay.py <name>` draws both outlines on top of each other.
|
||||||
|
- `sheet.py` tiles captured frames.
|
||||||
|
|
||||||
|
To see it in game, add the JSON to the testmod's `player_animations` and `EMOTECRAFT_EMOTES`, and add
|
||||||
|
shots to `EmotesGameTest`.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- A loop takes about 45 minutes on 7 cores.
|
||||||
|
- The emote comes out with a keyframe on almost every tick for every channel.
|
||||||
|
- Other rigs differ from ours (a knee made of two rigid boxes, a torso that doesn't bend), so a fit is
|
||||||
|
our closest match, not an exact copy.
|
||||||
|
- Extreme poses can still land in a wrong answer on the first tick. `synth_test.py`'s 80° lean does.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Captures an emote from a store page's 3D viewer, one frame per tick from several fixed cameras.
|
||||||
|
|
||||||
|
.venv/bin/python capture.py <store url> <name> [--seconds 6]
|
||||||
|
|
||||||
|
Writes frames/<name>/<view>/<tick>.png (RGBA, transparent background) and frames/<name>/views.json,
|
||||||
|
which records each camera exactly so measure.py can render the same views.
|
||||||
|
|
||||||
|
The page runs on a clock that viewer.js steps by hand, so every view of a tick shows the same moment
|
||||||
|
and nothing is missed however fast the emote moves.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
TICK_MS = 50
|
||||||
|
# Where the cameras look (viewer world units) and from how far. The model stands about one unit per
|
||||||
|
# model pixel, with its middle about 12 units below the viewer's origin.
|
||||||
|
TARGET = [0, -12, 0]
|
||||||
|
DISTANCE = 50
|
||||||
|
FOV = 50
|
||||||
|
VIEWS = {f"y{yaw:03d}": (yaw, 0) for yaw in range(0, 360, 45)}
|
||||||
|
VIEWS |= {"top000": (0, 50), "top090": (90, 50)}
|
||||||
|
|
||||||
|
|
||||||
|
def step(page, ms, grab=False):
|
||||||
|
url = page.evaluate(f"window.__capture.step({ms}, {'true' if grab else 'false'})")
|
||||||
|
if not grab:
|
||||||
|
return None
|
||||||
|
return Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).convert("RGBA")
|
||||||
|
|
||||||
|
|
||||||
|
def warm_up(page, frames):
|
||||||
|
for _ in range(frames):
|
||||||
|
step(page, 16)
|
||||||
|
page.wait_for_timeout(15)
|
||||||
|
|
||||||
|
|
||||||
|
def set_view(page, yaw, pitch):
|
||||||
|
view = {"target": TARGET, "distance": DISTANCE, "yaw": yaw, "pitch": pitch, "fov": FOV}
|
||||||
|
page.evaluate(f"window.__capture.setView({json.dumps(view)})")
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
def choose_skin(page, skin):
|
||||||
|
"""Puts a skin we have the texture of on the model, so measure.py can match colours too."""
|
||||||
|
page.evaluate("[...document.querySelectorAll('button')].find(b => b.innerText.includes(\"'s skin\")).click()")
|
||||||
|
warm_up(page, 40)
|
||||||
|
page.get_by_text("Change preview skin").last.click(force=True)
|
||||||
|
warm_up(page, 60)
|
||||||
|
preset = page.get_by_role("button", name=skin, exact=True)
|
||||||
|
if preset.count():
|
||||||
|
preset.first.click(force=True)
|
||||||
|
else:
|
||||||
|
page.get_by_placeholder("Enter a Minecraft username").fill(skin)
|
||||||
|
page.keyboard.press("Enter")
|
||||||
|
warm_up(page, 150)
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
|
||||||
|
|
||||||
|
def loop_ticks(masks):
|
||||||
|
"""The loop length in ticks: the shift that best maps the front view's masks onto themselves."""
|
||||||
|
best, best_err = None, None
|
||||||
|
for period in range(8, len(masks) // 2):
|
||||||
|
err = np.mean([np.mean(masks[i] != masks[i + period]) for i in range(len(masks) - period)])
|
||||||
|
if best_err is None or err < best_err:
|
||||||
|
best, best_err = period, err
|
||||||
|
return best, best_err
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("url")
|
||||||
|
parser.add_argument("name")
|
||||||
|
parser.add_argument("--seconds", type=float, default=6)
|
||||||
|
parser.add_argument("--skin", default="Steve",
|
||||||
|
help="a preset from the viewer's skin dialog (Steve, Alex, ...) or a Minecraft username")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
out = Path("frames") / args.name
|
||||||
|
ticks = round(args.seconds * 1000 / TICK_MS)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.firefox.launch()
|
||||||
|
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
||||||
|
page.add_init_script(path=str(Path(__file__).with_name("viewer.js")))
|
||||||
|
page.goto(args.url, wait_until="domcontentloaded", timeout=60000)
|
||||||
|
warm_up(page, 200)
|
||||||
|
# The toggle between the video and the 3D viewer sits in the preview's top right corner.
|
||||||
|
page.mouse.click(794, 177)
|
||||||
|
page.wait_for_selector("#skin-viewer", timeout=30000)
|
||||||
|
warm_up(page, 100)
|
||||||
|
choose_skin(page, args.skin)
|
||||||
|
warm_up(page, 200)
|
||||||
|
|
||||||
|
cameras = {}
|
||||||
|
for name, (yaw, pitch) in VIEWS.items():
|
||||||
|
(out / name).mkdir(parents=True, exist_ok=True)
|
||||||
|
cameras[name] = set_view(page, yaw, pitch)
|
||||||
|
|
||||||
|
info = page.evaluate("window.__capture.camera_info()")
|
||||||
|
start = page.evaluate("window.__capture.time()")
|
||||||
|
front_masks = []
|
||||||
|
for tick in range(ticks):
|
||||||
|
for i, (name, (yaw, pitch)) in enumerate(VIEWS.items()):
|
||||||
|
set_view(page, yaw, pitch)
|
||||||
|
# The first view advances the clock by a tick; the rest redraw the same moment.
|
||||||
|
frame = step(page, TICK_MS if i == 0 else 0, grab=True)
|
||||||
|
frame.save(out / name / f"{tick:04d}.png")
|
||||||
|
if name == "y000":
|
||||||
|
front_masks.append(np.asarray(frame)[:, :, 3] > 127)
|
||||||
|
if tick % 20 == 0:
|
||||||
|
print(f"tick {tick}/{ticks}")
|
||||||
|
|
||||||
|
period, err = loop_ticks(front_masks)
|
||||||
|
meta = {
|
||||||
|
"url": args.url,
|
||||||
|
"skin": args.skin,
|
||||||
|
"tick_ms": TICK_MS,
|
||||||
|
"ticks": ticks,
|
||||||
|
"start_ms": start,
|
||||||
|
"size": list(front_masks[0].shape[::-1]),
|
||||||
|
"aspect": info["aspect"],
|
||||||
|
"near": info["near"],
|
||||||
|
"views": cameras,
|
||||||
|
"loop_ticks": period,
|
||||||
|
"loop_error": err,
|
||||||
|
}
|
||||||
|
(out / "views.json").write_text(json.dumps(meta, indent=2))
|
||||||
|
print(f"loop: {period} ticks ({period * TICK_MS / 1000:.2f} s), mismatch {err:.4f}")
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Compares model.LimbBend with the Java LimbBend, point for point.
|
||||||
|
|
||||||
|
.venv/bin/python check_port.py ../../core/build/classes/java/main # after ./gradlew :core:compileJava
|
||||||
|
|
||||||
|
Runs CheckPort.java against the compiled core classes and fails if any result differs by 1e-3 or more.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from model import LimbBend
|
||||||
|
|
||||||
|
CASES = [(-2, 10, 0.5, 0, 0), (-2, 10, 1.75, 3.5, 0), (0, 12, 1.2, 2.79, 0), (0, 12, 0.7, 3.14159, 1), (0, 12, -0.9, 1.0, 1)]
|
||||||
|
POINTS = np.array([[0, 0, 0], [1.5, 3, -2.25], [-2.25, 10.25, 2.25], [2, -2.25, 1], [0.5, 12.25, -1]])
|
||||||
|
|
||||||
|
classes = sys.argv[1]
|
||||||
|
java = subprocess.run(["java", "-cp", classes, "CheckPort.java"], capture_output=True, text=True, check=True).stdout
|
||||||
|
expected = np.array([[float(v) for v in line.split()] for line in java.strip().splitlines()])
|
||||||
|
got = []
|
||||||
|
for start, end, angle, axis, anchored in CASES:
|
||||||
|
b = LimbBend(start, end, angle, axis, anchored_at_end=bool(anchored))
|
||||||
|
bent = b.bend_points(POINTS)
|
||||||
|
got += [[*bent[i], b.angle_at(POINTS[i, 1])] for i in range(len(POINTS))]
|
||||||
|
err = np.abs(np.array(got) - expected).max()
|
||||||
|
print(f"max difference from Java: {err:.2e}")
|
||||||
|
sys.exit(0 if err < 1e-3 else 1)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Puts captured frames next to our render of the fitted pose, with the same skin and cameras.
|
||||||
|
|
||||||
|
.venv/bin/python compare.py <name> [tick] [views]
|
||||||
|
|
||||||
|
Writes frames/<name>/compare.png: for each view, theirs on the left and ours on the right.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from model import Model, rig_to_pose
|
||||||
|
from render import Camera, render_colors
|
||||||
|
|
||||||
|
name = sys.argv[1]
|
||||||
|
tick = int(sys.argv[2]) if len(sys.argv) > 2 else 0
|
||||||
|
views = sys.argv[3].split(",") if len(sys.argv) > 3 else ["y000", "y090", "y180", "y270", "y045"]
|
||||||
|
base = Path("frames") / name
|
||||||
|
meta = json.loads((base / "views.json").read_text())
|
||||||
|
data = json.loads((base / "rig.json").read_text())
|
||||||
|
rig = np.array(next(t["rig"] for t in data["ticks"] if t["tick"] == tick))
|
||||||
|
skin = np.asarray(Image.open(Path("skins") / f"{meta.get('skin', 'Steve')}.png").convert("RGBA"))
|
||||||
|
model = Model(spacing=0.12, skin=skin)
|
||||||
|
scale = 2
|
||||||
|
cams = {v: Camera(meta["views"][v], meta["size"], meta["aspect"], scale) for v in views}
|
||||||
|
ours = render_colors(np.concatenate(list(model.pose(rig_to_pose(rig)).values())), model.colors, data["calib"], cams)
|
||||||
|
bg = np.array([40, 40, 52], np.uint8)
|
||||||
|
tiles = []
|
||||||
|
for v in views:
|
||||||
|
theirs = np.asarray(Image.open(base / v / f"{tick:04d}.png").convert("RGBA").reduce(scale))
|
||||||
|
a = np.where(theirs[..., 3:] > 127, theirs[..., :3], bg)
|
||||||
|
image, mask = ours[v]
|
||||||
|
b = np.where(mask[..., None], (image * 255).astype(np.uint8), bg)
|
||||||
|
tiles.append(np.concatenate([a, b], axis=1))
|
||||||
|
sheet = np.concatenate(tiles, axis=0)
|
||||||
|
ys, xs = np.nonzero((sheet != bg).any(axis=2))
|
||||||
|
Image.fromarray(sheet[:, max(xs.min() - 8, 0):xs.max() + 8]).save(base / "compare.png")
|
||||||
|
print("wrote", base / "compare.png")
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Turns measured rigs (frames/<name>/rig.json) into an Emotecraft emote our loader reads.
|
||||||
|
|
||||||
|
.venv/bin/python fit.py <name> [--out path.json] [--angle-tol 1] [--offset-tol 0.1]
|
||||||
|
|
||||||
|
Every channel is keyed independently, keeping only the ticks linear interpolation can't reproduce
|
||||||
|
within the tolerances. Values go through the inverse of EmotecraftLoader's conversions.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from model import INDEX, Model, rest_rig, rig_to_pose
|
||||||
|
|
||||||
|
PARTS = {"root": "body", "head": "head", "body": "torso", "right_arm": "rightArm", "left_arm": "leftArm",
|
||||||
|
"right_leg": "rightLeg", "left_leg": "leftLeg"}
|
||||||
|
# EmotecraftLoader.REST_PIVOTS: limb positions in the file are absolute pivots.
|
||||||
|
REST = {"right_arm": (-5, 2, 0), "left_arm": (5, 2, 0), "right_leg": (-1.9, 12, 0.1), "left_leg": (1.9, 12, 0.1)}
|
||||||
|
CHANNELS = ["x", "y", "z", "pitch", "yaw", "roll", "bend", "axis"]
|
||||||
|
|
||||||
|
|
||||||
|
def to_file(bone, channel, value):
|
||||||
|
"""The inverse of EmotecraftLoader.convert, writing degrees."""
|
||||||
|
if channel in ("x", "y", "z"):
|
||||||
|
if bone == "root":
|
||||||
|
return value / 16 if channel == "z" else -value / 16
|
||||||
|
return value + REST.get(bone, (0, 0, 0))["xyz".index(channel)]
|
||||||
|
degrees = np.degrees(value)
|
||||||
|
if channel == "bend":
|
||||||
|
# Limbs bend the other way in Emotecraft; the torso agrees with ours.
|
||||||
|
return degrees if bone == "body" else -degrees
|
||||||
|
if bone == "root" and channel in ("pitch", "yaw"):
|
||||||
|
return -degrees
|
||||||
|
return degrees
|
||||||
|
|
||||||
|
|
||||||
|
def keep(values, tol):
|
||||||
|
"""Indices to key so linear interpolation stays within tol of every sample (Douglas-Peucker)."""
|
||||||
|
keys = {0, len(values) - 1}
|
||||||
|
|
||||||
|
def split(a, b):
|
||||||
|
if b - a < 2:
|
||||||
|
return
|
||||||
|
t = np.arange(a, b + 1)
|
||||||
|
line = values[a] + (values[b] - values[a]) * (t - a) / (b - a)
|
||||||
|
err = np.abs(values[a:b + 1] - line)
|
||||||
|
i = int(np.argmax(err))
|
||||||
|
if err[i] > tol:
|
||||||
|
keys.add(a + i)
|
||||||
|
split(a, a + i)
|
||||||
|
split(a + i, b)
|
||||||
|
|
||||||
|
split(0, len(values) - 1)
|
||||||
|
return sorted(keys)
|
||||||
|
|
||||||
|
|
||||||
|
def ground(rigs):
|
||||||
|
"""Shifts every tick vertically so the lowest foot over the whole emote is where a standing
|
||||||
|
player's feet are. The viewer offset was a guess (measure.py keeps it fixed), so the measured
|
||||||
|
height is only right up to a constant."""
|
||||||
|
model = Model()
|
||||||
|
lowest = lambda rig: max(model.pose(rig_to_pose(rig))[leg][:, 1].max() for leg in ("right_leg", "left_leg"))
|
||||||
|
shift = lowest(rest_rig()) - max(lowest(r) for r in rigs) # model Y points down
|
||||||
|
for r in rigs:
|
||||||
|
r[INDEX["root_y"]] += shift
|
||||||
|
return shift
|
||||||
|
|
||||||
|
|
||||||
|
def periodic_smooth(values, harmonics, angle):
|
||||||
|
"""A loop's channel kept to its first few Fourier harmonics: removes the fit's tick-to-tick noise
|
||||||
|
and joins the loop's seam. Returns the smoothed samples plus the loop's next sample (its start
|
||||||
|
again, a whole number of turns on for an angle that spins)."""
|
||||||
|
n = len(values)
|
||||||
|
turns = 0.0
|
||||||
|
if angle:
|
||||||
|
# How far the angle travels in one loop: nothing for a sway, whole turns for a spin.
|
||||||
|
step = (values[0] - values[-1] + np.pi) % (2 * np.pi) - np.pi
|
||||||
|
turns = values[-1] + step - values[0]
|
||||||
|
ramp = turns * np.arange(n + 1) / n
|
||||||
|
spectrum = np.fft.rfft(values - ramp[:n])
|
||||||
|
spectrum[harmonics + 1:] = 0
|
||||||
|
smooth = np.fft.irfft(spectrum, n)
|
||||||
|
return np.append(smooth, smooth[0]) + ramp
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("name")
|
||||||
|
parser.add_argument("--out")
|
||||||
|
parser.add_argument("--angle-tol", type=float, default=1.0, help="degrees")
|
||||||
|
parser.add_argument("--offset-tol", type=float, default=0.1, help="pixels")
|
||||||
|
parser.add_argument("--no-ground", action="store_true", help="keep the measured height")
|
||||||
|
parser.add_argument("--harmonics", type=int, default=4,
|
||||||
|
help="for a whole loop, how many Fourier harmonics each channel keeps (0 = no smoothing)")
|
||||||
|
parser.add_argument("--rig", help="read this rig file instead of frames/<name>/rig.json")
|
||||||
|
parser.add_argument("--symmetric", action="store_true",
|
||||||
|
help="for a whole loop that sways once per loop, remove its left/right lopsidedness "
|
||||||
|
"(see symmetry.py)")
|
||||||
|
parser.add_argument("--title", help="the emote's name (default: from <name>)")
|
||||||
|
parser.add_argument("--description", help="default: where it was measured from")
|
||||||
|
parser.add_argument("--author", default="Saturn capture")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
base = Path("frames") / args.name
|
||||||
|
data = json.loads(Path(args.rig).read_text() if args.rig else (base / "rig.json").read_text())
|
||||||
|
meta = json.loads((base / "views.json").read_text())
|
||||||
|
ticks = data["ticks"]
|
||||||
|
loop = meta.get("loop_ticks")
|
||||||
|
rigs = [np.array(t["rig"]) for t in ticks]
|
||||||
|
if args.symmetric:
|
||||||
|
if not (loop and len(rigs) == loop):
|
||||||
|
parser.error("--symmetric needs a whole loop")
|
||||||
|
from symmetry import symmetrize
|
||||||
|
rigs = list(symmetrize(rigs, sway=True))
|
||||||
|
print("made symmetric: each tick averaged with the mirror of the pose half a loop on")
|
||||||
|
if not args.no_ground:
|
||||||
|
print(f"grounded: moved {ground(rigs):+.2f} px down")
|
||||||
|
poses = [rig_to_pose(r) for r in rigs]
|
||||||
|
first = ticks[0]["tick"]
|
||||||
|
whole_loop = bool(loop) and len(poses) == loop
|
||||||
|
if whole_loop and args.harmonics:
|
||||||
|
print(f"smoothed each channel to {args.harmonics} harmonics of the {loop}-tick loop")
|
||||||
|
|
||||||
|
moves = {}
|
||||||
|
total = 0
|
||||||
|
for bone, part in PARTS.items():
|
||||||
|
for channel in CHANNELS:
|
||||||
|
raw = np.array([getattr(p[bone], channel) for p in poses], float)
|
||||||
|
angle = channel not in ("x", "y", "z")
|
||||||
|
if angle:
|
||||||
|
raw = np.unwrap(raw)
|
||||||
|
if whole_loop:
|
||||||
|
# A loop's last tick blends back into its first.
|
||||||
|
raw = periodic_smooth(raw, args.harmonics, angle) if args.harmonics else np.append(raw, raw[0])
|
||||||
|
values = np.array([to_file(bone, channel, v) for v in raw])
|
||||||
|
if np.all(np.abs(values - values[0]) < 1e-6) and abs(values[0]) < 1e-6 and channel != "x":
|
||||||
|
continue
|
||||||
|
tol = args.offset_tol if channel in ("x", "y", "z") else args.angle_tol
|
||||||
|
if bone == "root" and channel in ("x", "y", "z"):
|
||||||
|
tol /= 16
|
||||||
|
for i in keep(values, tol):
|
||||||
|
moves.setdefault(i, {}).setdefault(part, {})[channel] = round(float(values[i]), 4)
|
||||||
|
total += 1
|
||||||
|
|
||||||
|
emote = {
|
||||||
|
"version": 3,
|
||||||
|
"name": args.title or args.name.replace("_", " ").title(),
|
||||||
|
"description": args.description or f"Measured from {meta['url']}",
|
||||||
|
"author": args.author,
|
||||||
|
"emote": {
|
||||||
|
"degrees": True,
|
||||||
|
"isLoop": bool(loop),
|
||||||
|
"returnTick": 0,
|
||||||
|
"endTick": len(poses) - 1,
|
||||||
|
"stopTick": len(poses) if whole_loop else len(poses) - 1,
|
||||||
|
"moves": [{"tick": i, "easing": "LINEAR", **parts} for i, parts in sorted(moves.items())],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out = Path(args.out) if args.out else base / f"{args.name}.json"
|
||||||
|
out.write_text(json.dumps(emote, indent=2))
|
||||||
|
print(f"wrote {out}: {len(emote['emote']['moves'])} ticks keyed, {total} channel keyframes "
|
||||||
|
f"(from {len(poses)} samples, starting at captured tick {first})")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""The back-to-front twin of a rig, which covers the same space with the textures turned round.
|
||||||
|
|
||||||
|
The head and torso boxes are centred on their pivots, so spinning either half a turn around its own
|
||||||
|
vertical axis leaves it covering the same space; only which face shows changes. The arms hang from
|
||||||
|
the torso's sides, so spinning the torso carries each arm to the other side. That twin (torso and
|
||||||
|
head facing backwards, arms swapped) is a separate local minimum the search can't walk out of, so
|
||||||
|
measure.py jumps to it directly, refines it, and keeps whichever scores lower. Only the colours tell
|
||||||
|
them apart.
|
||||||
|
|
||||||
|
In rig terms: the torso and head get R Ry(pi); each arm takes the other's rotation times Ry(pi) (a
|
||||||
|
half turn around its own length, which moves its off-centre box to the other side of its pivot); and
|
||||||
|
bends keep their shape with their direction turned half round, since the frame they're measured in
|
||||||
|
turned. The legs hang from the hips and don't change.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from model import INDEX, rotation_zyx
|
||||||
|
|
||||||
|
SPIN = np.diag([-1.0, 1.0, -1.0]) # Ry(pi)
|
||||||
|
ARMS = {"right_arm": "left_arm", "left_arm": "right_arm"}
|
||||||
|
|
||||||
|
|
||||||
|
def euler_zyx(r):
|
||||||
|
"""(pitch, yaw, roll) with rotation_zyx(pitch, yaw, roll) == r."""
|
||||||
|
yaw = np.arcsin(np.clip(-r[2, 0], -1, 1))
|
||||||
|
return np.arctan2(r[2, 1], r[2, 2]), yaw, np.arctan2(r[1, 0], r[0, 0])
|
||||||
|
|
||||||
|
|
||||||
|
def _spun(rig, source):
|
||||||
|
g = lambda c: rig[INDEX[f"{source}_{c}"]]
|
||||||
|
return euler_zyx(rotation_zyx(g("pitch"), g("yaw"), g("roll")) @ SPIN)
|
||||||
|
|
||||||
|
|
||||||
|
def flip_head(rig):
|
||||||
|
"""Just the head spun round: a cube on its pivot, so the same space facing the other way."""
|
||||||
|
out = rig.copy()
|
||||||
|
for c, v in zip(("pitch", "yaw", "roll"), _spun(rig, "head")):
|
||||||
|
out[INDEX[f"head_{c}"]] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def twins(rig):
|
||||||
|
"""Every back-to-front twin worth trying: the torso with its arms, the head, and both."""
|
||||||
|
return [flip(rig), flip_head(rig), flip_head(flip(rig))]
|
||||||
|
|
||||||
|
|
||||||
|
def flip(rig):
|
||||||
|
out = rig.copy()
|
||||||
|
for part, source in [("body", "body"), ("head", "head"), *ARMS.items()]:
|
||||||
|
for c, v in zip(("pitch", "yaw", "roll"), _spun(rig, source)):
|
||||||
|
out[INDEX[f"{part}_{c}"]] = v
|
||||||
|
for part, source in [("body", "body"), *ARMS.items()]:
|
||||||
|
out[INDEX[f"{part}_bend"]] = rig[INDEX[f"{source}_bend"]]
|
||||||
|
out[INDEX[f"{part}_axis"]] = rig[INDEX[f"{source}_axis"]] + np.pi
|
||||||
|
return out
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"""Fits our rig to captured frames, tick by tick, by rendering it and matching outlines and colours.
|
||||||
|
|
||||||
|
.venv/bin/python measure.py <name> [--ticks 0:13] [--workers 7]
|
||||||
|
|
||||||
|
Writes frames/<name>/rig.json: the viewer calibration and one rig vector per tick (see model.RIG).
|
||||||
|
|
||||||
|
The first tick has nothing to start from, so it's searched in stages: the torso and head, then each
|
||||||
|
limb from several starts, then everything together, then its back-to-front twins (see flip.py). It
|
||||||
|
also finds the viewer's scale. Later ticks start from the tick before.
|
||||||
|
|
||||||
|
For a whole loop, that first pass is only a starting point: warm starts carry any drift from tick to
|
||||||
|
tick. Each later round smooths every channel over the loop (a few Fourier harmonics, which have to
|
||||||
|
close), and refits every tick held near that curve instead of near its neighbour.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import multiprocessing
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cma
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from flip import twins
|
||||||
|
from model import INDEX, RIG, Model, rest_rig, rig_to_pose
|
||||||
|
from render import Capture, score
|
||||||
|
|
||||||
|
# How far CMA-ES first looks along each parameter: pixels for offsets, radians for angles.
|
||||||
|
STEP = np.array([2.0 if name.endswith(("_x", "_y", "_z")) else 0.35 for name in RIG])
|
||||||
|
CALIB = ["scale", "view_x", "view_y", "view_z"]
|
||||||
|
CALIB_STEP = np.array([0.05, 2, 2, 2])
|
||||||
|
# Only the scale is fitted. Moving the viewer, the whole player and the hips all slide the whole model,
|
||||||
|
# so no frame can tell them apart: the viewer's offset stays fixed (it centres the player on its
|
||||||
|
# origin), and fit.py stands the finished emote on the ground instead.
|
||||||
|
CALIB_FREE = [0]
|
||||||
|
# Channels a silhouette barely sees (a limb's twist, which way a straight limb would bend) drift
|
||||||
|
# unless something holds them near zero.
|
||||||
|
QUIET = np.array([1.0 if name.endswith(("_yaw", "_axis")) and not name.startswith(("root", "body")) else 0.0
|
||||||
|
for name in RIG])
|
||||||
|
QUIET_WEIGHT = 0.02
|
||||||
|
SMOOTH_WEIGHT = 0.05
|
||||||
|
# The search scores colour lightly in a few views: outlines give it a smooth slope to follow, and
|
||||||
|
# colour changes abruptly as parts slide across texture edges, which strands it. Choosing between a
|
||||||
|
# fit and its back-to-front twins (whose outlines match) is colour's job, so that comparison weighs
|
||||||
|
# colour heavily in every view.
|
||||||
|
COLOR_VIEWS = {"y000", "y090", "y180", "y270"}
|
||||||
|
JUDGE_COLOR_WEIGHT = 2.0
|
||||||
|
|
||||||
|
# Anatomy, as soft limits: in a tight pose like a crouch, many limb twists cast nearly the same outline,
|
||||||
|
# and these pick the natural one. Nothing is penalised inside the ranges.
|
||||||
|
FOLD = {"right_leg": np.pi, "left_leg": np.pi, "right_arm": 0.0, "left_arm": 0.0} # knees back, elbows forward
|
||||||
|
FOLD_RANGE = np.radians(70)
|
||||||
|
ROLL_RANGE = np.radians(60)
|
||||||
|
PRIOR_WEIGHT = 0.5
|
||||||
|
HIP_CENTRE_WEIGHT = 0.002
|
||||||
|
# Leaning the torso further forward while arching it further back looks nearly the same from every
|
||||||
|
# camera, so a small cost on the torso's bend picks the straightest torso that fits.
|
||||||
|
BODY_BEND_WEIGHT = 0.008
|
||||||
|
# Where each bend direction is kept (see canonical): half a turn either side of this. Knees fold back
|
||||||
|
# and elbows forward; a torso may hunch (0) or arch (pi), so both sit well inside its range.
|
||||||
|
AXIS_CENTRE = FOLD | {"body": np.pi / 2}
|
||||||
|
|
||||||
|
|
||||||
|
def wrap(a):
|
||||||
|
return (a + np.pi) % (2 * np.pi) - np.pi
|
||||||
|
|
||||||
|
|
||||||
|
ANGLES = np.array([not name.endswith(("_x", "_y", "_z")) for name in RIG])
|
||||||
|
|
||||||
|
|
||||||
|
def change(rig, previous):
|
||||||
|
"""How far each channel moved, the short way round for angles: canonical() keeps them within
|
||||||
|
-pi..pi, so a knee folding back straddles the edge and would look like it swung a whole turn."""
|
||||||
|
d = rig - previous
|
||||||
|
return np.where(ANGLES, wrap(d), d)
|
||||||
|
|
||||||
|
|
||||||
|
def anatomy(rig):
|
||||||
|
"""How far outside natural joint ranges the rig is (0 inside them)."""
|
||||||
|
total = 0.0
|
||||||
|
for limb, fold in FOLD.items():
|
||||||
|
bend = rig[INDEX[f"{limb}_bend"]]
|
||||||
|
# A negative bend folds the other way.
|
||||||
|
axis = rig[INDEX[f"{limb}_axis"]] + (np.pi if bend < 0 else 0)
|
||||||
|
# Which way a limb folds only matters as much as it bends.
|
||||||
|
total += np.sin(min(abs(bend), np.pi) / 2) * max(0.0, abs(wrap(axis - fold)) - FOLD_RANGE) ** 2
|
||||||
|
total += max(0.0, abs(wrap(rig[INDEX[f"{limb}_roll"]])) - ROLL_RANGE) ** 2
|
||||||
|
return PRIOR_WEIGHT * total + HIP_CENTRE_WEIGHT * rig[INDEX["hip_x"]] ** 2
|
||||||
|
|
||||||
|
# The hips carry everything, so the whole player's offset would only duplicate them; it stays at zero.
|
||||||
|
# Turning the whole player duplicates turning every part, so it's only fitted with --root, for flips
|
||||||
|
# and spins, where it keeps the keyframes simple.
|
||||||
|
FITTED = [i for i, n in enumerate(RIG) if not n.startswith("root")]
|
||||||
|
ROOT_TURN = [INDEX[n] for n in ("root_pitch", "root_yaw", "root_roll")]
|
||||||
|
LIMBS = ("right_leg", "left_leg", "right_arm", "left_arm")
|
||||||
|
ALL_PARTS = ("head", "body", "right_arm", "left_arm", "right_leg", "left_leg")
|
||||||
|
|
||||||
|
# Per-process state, set by init_worker, so the pool only receives parameter vectors.
|
||||||
|
_state = {}
|
||||||
|
|
||||||
|
|
||||||
|
def init_worker(directory, skin_path, tick):
|
||||||
|
skin = np.asarray(Image.open(skin_path).convert("RGBA"))
|
||||||
|
cap = Capture(directory)
|
||||||
|
model = Model(skin=skin)
|
||||||
|
# Where each part's points sit in model.colors, to score some of the parts on their own.
|
||||||
|
bounds = np.cumsum([0] + [len(model.local[p]) for p in model.local])
|
||||||
|
spans = {p: (bounds[i], bounds[i + 1]) for i, p in enumerate(model.local)}
|
||||||
|
_state.update(model=model, spans=spans, cameras=cap.cameras, targets=cap.targets(tick))
|
||||||
|
|
||||||
|
|
||||||
|
def loss(job):
|
||||||
|
"""job = (full vector: rig + calib, previous rig or None, parts to score[, judging]). Scoring only
|
||||||
|
some parts (the torso stage) counts just their points outside the captured outline. Judging weighs
|
||||||
|
colour heavily in every view, to pick between twins."""
|
||||||
|
v, previous, parts, *judging = job
|
||||||
|
judging = bool(judging and judging[0])
|
||||||
|
rig, calib = v[:len(RIG)], v[len(RIG):]
|
||||||
|
m = _state["model"]
|
||||||
|
posed = m.pose(rig_to_pose(rig))
|
||||||
|
parts = parts or ALL_PARTS
|
||||||
|
pts = np.concatenate([posed[p] for p in parts])
|
||||||
|
colors = np.concatenate([m.colors[slice(*_state["spans"][p])] for p in parts])
|
||||||
|
total = score(pts, calib, _state["cameras"], _state["targets"], colors=colors,
|
||||||
|
color_views=None if judging else COLOR_VIEWS, one_way=len(parts) < len(ALL_PARTS),
|
||||||
|
color_weight=JUDGE_COLOR_WEIGHT if judging else None)
|
||||||
|
total += QUIET_WEIGHT * np.sum(QUIET * np.sin(rig) ** 2)
|
||||||
|
total += anatomy(rig)
|
||||||
|
total += BODY_BEND_WEIGHT * rig[INDEX["body_bend"]] ** 2
|
||||||
|
if previous is not None:
|
||||||
|
total += SMOOTH_WEIGHT * np.sum((change(rig, previous) / STEP) ** 2) / len(rig)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def fit(pool, x0, free, sigma, evals, previous=None, parts=None, popsize=24, seed=1):
|
||||||
|
"""CMA-ES over the indices in free (of rig + calib); the rest stay at x0."""
|
||||||
|
x0 = np.asarray(x0, float)
|
||||||
|
steps = np.concatenate([STEP, CALIB_STEP])[free]
|
||||||
|
|
||||||
|
def expand(sub):
|
||||||
|
v = x0.copy()
|
||||||
|
v[free] = sub
|
||||||
|
return v
|
||||||
|
|
||||||
|
es = cma.CMAEvolutionStrategy(x0[free], sigma, {
|
||||||
|
"CMA_stds": steps, "popsize": popsize, "maxfevals": evals, "verbose": -9, "seed": seed, "tolfun": 1e-4,
|
||||||
|
})
|
||||||
|
while not es.stop():
|
||||||
|
subs = es.ask()
|
||||||
|
es.tell(subs, pool.map(loss, [(expand(s), previous, parts) for s in subs]))
|
||||||
|
return expand(es.result.xbest), es.result.fbest
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(v):
|
||||||
|
"""A negative bend is the same shape as a positive one folding the other way. Bend directions are
|
||||||
|
kept within half a turn of AXIS_CENTRE, so a knee folding back doesn't sit on the edge of the range
|
||||||
|
and jump a whole turn between ticks."""
|
||||||
|
v = v.copy()
|
||||||
|
for part, centre in AXIS_CENTRE.items():
|
||||||
|
b, a = INDEX[f"{part}_bend"], INDEX[f"{part}_axis"]
|
||||||
|
if v[b] < 0:
|
||||||
|
v[b], v[a] = -v[b], v[a] + np.pi
|
||||||
|
v[a] = wrap(v[a] - centre) + centre
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def loop_curve(rigs, harmonics):
|
||||||
|
"""Each channel of a loop's rigs kept to its first few Fourier harmonics: the loop's own smooth
|
||||||
|
path, which has to come back to where it started."""
|
||||||
|
from fit import periodic_smooth
|
||||||
|
rigs = np.array(rigs)
|
||||||
|
curve = np.empty_like(rigs)
|
||||||
|
for i in range(rigs.shape[1]):
|
||||||
|
values = np.unwrap(rigs[:, i]) if ANGLES[i] else rigs[:, i]
|
||||||
|
curve[:, i] = periodic_smooth(values, harmonics, bool(ANGLES[i]))[:-1]
|
||||||
|
return curve
|
||||||
|
|
||||||
|
|
||||||
|
# Starting points for each limb on its own: swung back, hanging, or forward, straight or folded the
|
||||||
|
# natural way. One start lands in a different local minimum every run; the best of these doesn't.
|
||||||
|
LIMB_STARTS = [(pitch, bend) for pitch in (-1.2, 0.0, 1.2) for bend in (0.0, 1.5)]
|
||||||
|
|
||||||
|
|
||||||
|
def fit_limbs(pool, x, fitted, starts, evals):
|
||||||
|
"""Fits each limb in turn with the others held, keeping the best of the starts."""
|
||||||
|
fx = None
|
||||||
|
for limb in LIMBS:
|
||||||
|
free = [i for i in fitted if RIG[i].startswith(limb)]
|
||||||
|
best = None
|
||||||
|
for pitch, bend in starts or [(None, None)]:
|
||||||
|
x0 = x.copy()
|
||||||
|
if pitch is not None:
|
||||||
|
x0[free] = 0
|
||||||
|
x0[INDEX[f"{limb}_pitch"]] = pitch
|
||||||
|
x0[INDEX[f"{limb}_bend"]] = bend
|
||||||
|
x0[INDEX[f"{limb}_axis"]] = FOLD[limb]
|
||||||
|
result = fit(pool, x0, free, 1.0 if starts else 0.4, evals)
|
||||||
|
if best is None or result[1] < best[1]:
|
||||||
|
best = result
|
||||||
|
x, fx = best
|
||||||
|
return x, fx
|
||||||
|
|
||||||
|
|
||||||
|
def refine_scale(pool, x):
|
||||||
|
"""The viewer scale on its own, once the pose explains the whole outline."""
|
||||||
|
from scipy.optimize import minimize_scalar
|
||||||
|
|
||||||
|
def f(scale):
|
||||||
|
v = x.copy()
|
||||||
|
v[len(RIG)] = scale
|
||||||
|
return pool.map(loss, [(v, None, None)])[0]
|
||||||
|
|
||||||
|
result = minimize_scalar(f, bounds=(x[len(RIG)] * 0.85, x[len(RIG)] * 1.15), method="bounded",
|
||||||
|
options={"xatol": 1e-3})
|
||||||
|
v = x.copy()
|
||||||
|
v[len(RIG)] = result.x
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def settle_facing(pool, x, fx, fitted, previous, log):
|
||||||
|
"""Tries each back-to-front twin of a fit, refined, and keeps the best. They cover nearly the same
|
||||||
|
outline, so the search can't get from one to another by itself; only colour decides."""
|
||||||
|
judge = lambda v: pool.map(loss, [(v, previous, None, True)])[0]
|
||||||
|
best, best_judged = (x, fx), judge(x)
|
||||||
|
for twin in twins(x[:len(RIG)]):
|
||||||
|
candidate = fit(pool, np.concatenate([twin, x[len(RIG):]]), fitted, 0.3, 1500, previous)
|
||||||
|
judged = judge(candidate[0])
|
||||||
|
if judged < best_judged:
|
||||||
|
log(f" a turned-round twin fits better: {judged:.3f} < {best_judged:.3f} (judged on colour)")
|
||||||
|
best, best_judged = candidate, judged
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def first_tick(pool, calib, fitted, log):
|
||||||
|
"""Tries the player facing both ways, all the way through, since no single stage can tell them
|
||||||
|
apart reliably: the torso, then each limb from several starts, then each limb again with the
|
||||||
|
others in place, then everything together. The scale is held until the pose explains the whole
|
||||||
|
outline (shrinking always helps a partly wrong pose), then fitted on its own."""
|
||||||
|
torso = [i for i in fitted if RIG[i].startswith(("root", "hip", "body", "head"))]
|
||||||
|
x = np.concatenate([rest_rig(), calib])
|
||||||
|
# The torso and head alone, kept inside the outline and matched by colour.
|
||||||
|
x, fx = fit(pool, x, torso, 1.0, 3000, parts=("head", "body"))
|
||||||
|
log(f" torso {fx:.3f}")
|
||||||
|
x, fx = fit_limbs(pool, x, fitted, LIMB_STARTS, 500)
|
||||||
|
log(f" limbs {fx:.3f}")
|
||||||
|
x, fx = fit_limbs(pool, x, fitted, None, 1000)
|
||||||
|
x, fx = fit(pool, x, fitted, 0.3, 3000)
|
||||||
|
log(f" together {fx:.3f}")
|
||||||
|
# Facing can be wrong at any stage; settle it once the pose explains the whole outline.
|
||||||
|
x, fx = settle_facing(pool, x, fx, fitted, None, log)
|
||||||
|
x, fx = fit(pool, x, fitted, 0.3, 6000)
|
||||||
|
x = refine_scale(pool, x)
|
||||||
|
return fit(pool, x, fitted, 0.2, 3000)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("name")
|
||||||
|
parser.add_argument("--ticks", default="0:13")
|
||||||
|
parser.add_argument("--calib", default="0.9375,0,-1.5,0",
|
||||||
|
help="viewer scale (a starting guess) and offset (kept) as scale,x,y,z")
|
||||||
|
parser.add_argument("--workers", type=int, default=max(1, multiprocessing.cpu_count() - 1))
|
||||||
|
parser.add_argument("--rounds", type=int, default=2,
|
||||||
|
help="for a whole loop, how many times to refit every tick held near the loop's smoothed curve")
|
||||||
|
parser.add_argument("--harmonics", type=int, default=2,
|
||||||
|
help="Fourier harmonics in that curve: few, since it only decides what the frames can't")
|
||||||
|
parser.add_argument("--root", action="store_true", help="also turn the whole player (flips and spins)")
|
||||||
|
parser.add_argument("--resume", action="store_true",
|
||||||
|
help="start from the existing rig.json and only run the rounds")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
directory = Path("frames") / args.name
|
||||||
|
meta = json.loads((directory / "views.json").read_text())
|
||||||
|
skin_path = Path("skins") / f"{meta.get('skin', 'Steve')}.png"
|
||||||
|
if not skin_path.exists():
|
||||||
|
from skin import fetch
|
||||||
|
skin_path.write_bytes(fetch(meta["skin"])[0])
|
||||||
|
calib = [float(c) for c in args.calib.split(",")]
|
||||||
|
fitted = sorted(FITTED + (ROOT_TURN if args.root else []))
|
||||||
|
start, end = map(int, args.ticks.split(":"))
|
||||||
|
|
||||||
|
ticks = list(range(start, end))
|
||||||
|
whole_loop = meta.get("loop_ticks") == len(ticks)
|
||||||
|
out = directory / "rig.json"
|
||||||
|
by_tick = {}
|
||||||
|
|
||||||
|
def save():
|
||||||
|
results = [{k: v for k, v in by_tick[t].items() if k != "rig_array"} for t in ticks if t in by_tick]
|
||||||
|
out.write_text(json.dumps({"rig": RIG, "calib": calib, "scale": 4, "ticks": results}, indent=2))
|
||||||
|
|
||||||
|
def record(tick, x, fx, label, t0):
|
||||||
|
rig = canonical(x[:len(RIG)])
|
||||||
|
init_worker(directory, skin_path, tick)
|
||||||
|
per_view = score(np.concatenate(list(_state["model"].pose(rig_to_pose(rig)).values())), calib,
|
||||||
|
_state["cameras"], _state["targets"], True, _state["model"].colors, COLOR_VIEWS)
|
||||||
|
print(f"tick {tick}{label}: score {fx:.3f}, worst view {max(per_view, key=per_view.get)} "
|
||||||
|
f"{max(per_view.values()):.2f}, {time.time() - t0:.0f}s", flush=True)
|
||||||
|
by_tick[tick] = {"tick": tick, "rig": rig.tolist(), "rig_array": rig, "score": fx, "views": per_view}
|
||||||
|
save()
|
||||||
|
|
||||||
|
log = lambda m: print(m, flush=True)
|
||||||
|
if args.resume:
|
||||||
|
saved = json.loads(out.read_text())
|
||||||
|
calib = saved["calib"]
|
||||||
|
for t in saved["ticks"]:
|
||||||
|
by_tick[t["tick"]] = t | {"rig_array": canonical(np.array(t["rig"]))}
|
||||||
|
else:
|
||||||
|
# First pass: each tick warm-started from the one before.
|
||||||
|
previous = None
|
||||||
|
for tick in ticks:
|
||||||
|
t0 = time.time()
|
||||||
|
with multiprocessing.Pool(args.workers, init_worker, (directory, skin_path, tick)) as pool:
|
||||||
|
if previous is None:
|
||||||
|
x, fx = first_tick(pool, calib, fitted, log)
|
||||||
|
calib = [float(c) for c in x[len(RIG):]]
|
||||||
|
log("calibration: " + ", ".join(f"{name} {c:.3f}" for name, c in zip(CALIB, calib)))
|
||||||
|
else:
|
||||||
|
x, fx = fit(pool, np.concatenate([previous, calib]), fitted, 0.3, 4000, previous)
|
||||||
|
x, fx = settle_facing(pool, x, fx, fitted, previous, log)
|
||||||
|
record(tick, x, fx, "", t0)
|
||||||
|
previous = by_tick[tick]["rig_array"]
|
||||||
|
|
||||||
|
# Later rounds, for a loop: every tick held near the loop's smoothed curve. No tick depends on the
|
||||||
|
# one fitted before it, so drift can't build up, and the curve has to close.
|
||||||
|
for r in range(args.rounds if whole_loop else 0):
|
||||||
|
curve = loop_curve([by_tick[t]["rig_array"] for t in ticks], args.harmonics)
|
||||||
|
for i, tick in enumerate(ticks):
|
||||||
|
t0 = time.time()
|
||||||
|
anchor = canonical(curve[i])
|
||||||
|
with multiprocessing.Pool(args.workers, init_worker, (directory, skin_path, tick)) as pool:
|
||||||
|
# From its own fit and from the curve: the curve can pull a tick off a ridge its own
|
||||||
|
# fit slid along.
|
||||||
|
x, fx = min((fit(pool, np.concatenate([start, calib]), fitted, 0.2, 2000, anchor)
|
||||||
|
for start in (by_tick[tick]["rig_array"], anchor)), key=lambda c: c[1])
|
||||||
|
record(tick, x, fx, f" (round {r + 1})", t0)
|
||||||
|
print("wrote", directory / "rig.json")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""The player model and our pose maths, ported from the library so poses can be rendered in Python.
|
||||||
|
|
||||||
|
Mirrors core's LimbBend and Bezier and the fabric mod's PoseApplier: same part boxes and pivots, same
|
||||||
|
rotation order (ModelPart's Z, then Y, then X), the same bends, and the same carrying of the head and
|
||||||
|
arms by a bent torso. check_port.py compares it against the Java classes.
|
||||||
|
|
||||||
|
Model space is Minecraft's: pixels, Y down, the player faces -Z, and its right side is at -X.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
BONES = ["root", "head", "body", "right_arm", "left_arm", "right_leg", "left_leg"]
|
||||||
|
|
||||||
|
# Default pivots and boxes (from, size) of the vanilla player model with wide arms.
|
||||||
|
PIVOTS = {
|
||||||
|
"head": (0, 0, 0),
|
||||||
|
"body": (0, 0, 0),
|
||||||
|
"right_arm": (-5, 2, 0),
|
||||||
|
"left_arm": (5, 2, 0),
|
||||||
|
"right_leg": (-1.9, 12, 0),
|
||||||
|
"left_leg": (1.9, 12, 0),
|
||||||
|
}
|
||||||
|
BOXES = {
|
||||||
|
"head": ((-4, -8, -4), (8, 8, 8)),
|
||||||
|
"body": ((-4, 0, -2), (8, 12, 4)),
|
||||||
|
"right_arm": ((-3, -2, -2), (4, 12, 4)),
|
||||||
|
"left_arm": ((-1, -2, -2), (4, 12, 4)),
|
||||||
|
"right_leg": ((-2, 0, -2), (4, 12, 4)),
|
||||||
|
"left_leg": ((-2, 0, -2), (4, 12, 4)),
|
||||||
|
}
|
||||||
|
# The outer skin layer (hat, jacket, sleeves, pants) is this much bigger on every side.
|
||||||
|
OVERLAY = {"head": 0.5, "body": 0.25, "right_arm": 0.25, "left_arm": 0.25, "right_leg": 0.25, "left_leg": 0.25}
|
||||||
|
# Where each part's base and outer layer start in a 64x64 skin.
|
||||||
|
UV = {
|
||||||
|
"head": ((0, 0), (32, 0)),
|
||||||
|
"body": ((16, 16), (16, 32)),
|
||||||
|
"right_arm": ((40, 16), (40, 32)),
|
||||||
|
"left_arm": ((32, 48), (48, 48)),
|
||||||
|
"right_leg": ((0, 16), (0, 32)),
|
||||||
|
"left_leg": ((16, 48), (0, 48)),
|
||||||
|
}
|
||||||
|
|
||||||
|
SHARPNESS = 0.5
|
||||||
|
SEGMENTS = 12
|
||||||
|
ARC_SAMPLES = 64
|
||||||
|
ROOT_PIVOT_Y = 12.8
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Transform:
|
||||||
|
"""BoneTransform: offset in pixels, rotations and bend in radians."""
|
||||||
|
x: float = 0
|
||||||
|
y: float = 0
|
||||||
|
z: float = 0
|
||||||
|
pitch: float = 0
|
||||||
|
yaw: float = 0
|
||||||
|
roll: float = 0
|
||||||
|
bend: float = 0
|
||||||
|
axis: float = 0
|
||||||
|
|
||||||
|
|
||||||
|
def rotation_zyx(pitch, yaw, roll):
|
||||||
|
"""The matrix ModelPart.translateAndRotate applies: Rz(roll) Ry(yaw) Rx(pitch)."""
|
||||||
|
cx, sx = np.cos(pitch), np.sin(pitch)
|
||||||
|
cy, sy = np.cos(yaw), np.sin(yaw)
|
||||||
|
cz, sz = np.cos(roll), np.sin(roll)
|
||||||
|
rx = np.array([[1, 0, 0], [0, cx, -sx], [0, sx, cx]])
|
||||||
|
ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]])
|
||||||
|
rz = np.array([[cz, -sz, 0], [sz, cz, 0], [0, 0, 1]])
|
||||||
|
return rz @ ry @ rx
|
||||||
|
|
||||||
|
|
||||||
|
def axis_angle(axis, angle):
|
||||||
|
x, y, z = axis
|
||||||
|
c, s = np.cos(angle), np.sin(angle)
|
||||||
|
k = np.array([[0, -z, y], [z, 0, -x], [-y, x, 0]])
|
||||||
|
return np.eye(3) + s * k + (1 - c) * (k @ k)
|
||||||
|
|
||||||
|
|
||||||
|
def _bezier(points, t):
|
||||||
|
"""Points on a cubic Bézier at each t (an array), like core's Bezier.point."""
|
||||||
|
t = np.asarray(t, float)[:, None]
|
||||||
|
p0, p1, p2, p3 = points
|
||||||
|
u = 1 - t
|
||||||
|
return u ** 3 * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t ** 3 * p3
|
||||||
|
|
||||||
|
|
||||||
|
def _bezier_derivative(points, t):
|
||||||
|
t = np.asarray(t, float)[:, None]
|
||||||
|
p0, p1, p2, p3 = points
|
||||||
|
u = 1 - t
|
||||||
|
return 3 * u * u * (p1 - p0) + 6 * u * t * (p2 - p1) + 3 * t * t * (p3 - p2)
|
||||||
|
|
||||||
|
|
||||||
|
class LimbBend:
|
||||||
|
"""Port of core's LimbBend. Vectorised over points."""
|
||||||
|
|
||||||
|
def __init__(self, start, end, angle, axis, sharpness=SHARPNESS, segments=SEGMENTS, anchored_at_end=False):
|
||||||
|
self.start = start
|
||||||
|
self.length = end - start
|
||||||
|
self.anchored_at_end = anchored_at_end
|
||||||
|
self.segments = segments
|
||||||
|
self.angle = angle
|
||||||
|
self.dir = np.array([np.sin(axis), 0, -np.cos(axis)])
|
||||||
|
self.n = np.array([self.dir[2], 0, -self.dir[0]])
|
||||||
|
self.ring = np.zeros((segments + 1, 3))
|
||||||
|
self.ring_angle = np.zeros(segments + 1)
|
||||||
|
if self.straight:
|
||||||
|
self.ring[:, 1] = start + self.length * np.arange(segments + 1) / segments
|
||||||
|
return
|
||||||
|
self._build(self._spine(min(1, max(0, sharpness))))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def straight(self):
|
||||||
|
return abs(self.angle) < 1e-4
|
||||||
|
|
||||||
|
def _spine(self, sharpness):
|
||||||
|
half = self.length / 2
|
||||||
|
top = np.array([0, self.start, 0.0])
|
||||||
|
joint = np.array([0, self.start + half, 0.0])
|
||||||
|
lower = np.array([self.dir[0] * np.sin(self.angle), np.cos(self.angle), self.dir[2] * np.sin(self.angle)])
|
||||||
|
bottom = joint + lower * half
|
||||||
|
pull = 2 / 3 + sharpness / 3
|
||||||
|
return [top, top + (joint - top) * pull, bottom + (joint - bottom) * pull, bottom]
|
||||||
|
|
||||||
|
def _build(self, curve):
|
||||||
|
ts = np.arange(ARC_SAMPLES + 1) / ARC_SAMPLES
|
||||||
|
pts = _bezier(curve, ts)
|
||||||
|
lengths = np.concatenate([[0], np.cumsum(np.linalg.norm(np.diff(pts, axis=0), axis=1))])
|
||||||
|
total = lengths[-1]
|
||||||
|
scale = self.length / total
|
||||||
|
top = pts[0]
|
||||||
|
# For each ring, the arc-length sample it falls in (LimbBend's while loop), then t within it.
|
||||||
|
target = total * np.arange(self.segments + 1) / self.segments
|
||||||
|
sample = np.clip(np.searchsorted(lengths, target, side="left") - 1, 0, ARC_SAMPLES - 1)
|
||||||
|
span = lengths[sample + 1] - lengths[sample]
|
||||||
|
f = np.where(span > 0, (target - lengths[sample]) / np.where(span > 0, span, 1), 0)
|
||||||
|
t = ts[sample] + (ts[sample + 1] - ts[sample]) * f
|
||||||
|
self.ring = top + (_bezier(curve, t) - top) * scale
|
||||||
|
tangent = _bezier_derivative(curve, t)
|
||||||
|
self.ring_angle = np.arctan2(tangent @ self.dir, tangent[:, 1])
|
||||||
|
|
||||||
|
def _mirror(self, y):
|
||||||
|
return 2 * self.start + self.length - y
|
||||||
|
|
||||||
|
def _rotate(self, v, a):
|
||||||
|
"""Rodrigues' rotation of rows of v around n by per-row angles a."""
|
||||||
|
n = self.n
|
||||||
|
cos, sin = np.cos(a)[:, None], np.sin(a)[:, None]
|
||||||
|
cross = np.cross(np.broadcast_to(n, v.shape), v)
|
||||||
|
dot = (v @ n)[:, None]
|
||||||
|
return v * cos + cross * sin + n * dot * (1 - cos)
|
||||||
|
|
||||||
|
def _from_start(self, p):
|
||||||
|
along = (p[:, 1] - self.start) / self.length * self.segments
|
||||||
|
ring = np.clip(np.floor(along).astype(int), 0, self.segments - 1)
|
||||||
|
f = np.clip(along - ring, 0, 1)
|
||||||
|
extra = np.where(along <= 0, p[:, 1] - self.start,
|
||||||
|
np.where(along >= self.segments, p[:, 1] - (self.start + self.length), 0))
|
||||||
|
f = np.where(along <= 0, 0, np.where(along >= self.segments, 1, f))
|
||||||
|
center = self.ring[ring] + (self.ring[ring + 1] - self.ring[ring]) * f[:, None]
|
||||||
|
a = self.ring_angle[ring] + (self.ring_angle[ring + 1] - self.ring_angle[ring]) * f
|
||||||
|
local = np.stack([p[:, 0], extra, p[:, 2]], axis=1)
|
||||||
|
return self._rotate(local, a) + center
|
||||||
|
|
||||||
|
def bend_points(self, p):
|
||||||
|
if self.anchored_at_end:
|
||||||
|
q = p.copy()
|
||||||
|
q[:, 1] = self._mirror(q[:, 1])
|
||||||
|
out = self._from_start(q)
|
||||||
|
out[:, 1] = self._mirror(out[:, 1])
|
||||||
|
return out
|
||||||
|
return self._from_start(p)
|
||||||
|
|
||||||
|
def _angle_from_start(self, y):
|
||||||
|
along = np.clip((y - self.start) / self.length * self.segments, 0, self.segments)
|
||||||
|
ring = min(int(along), self.segments - 1)
|
||||||
|
return self.ring_angle[ring] + (self.ring_angle[ring + 1] - self.ring_angle[ring]) * (along - ring)
|
||||||
|
|
||||||
|
def angle_at(self, y):
|
||||||
|
return -self._angle_from_start(self._mirror(y)) if self.anchored_at_end else self._angle_from_start(y)
|
||||||
|
|
||||||
|
|
||||||
|
def limb_bend(bone, t):
|
||||||
|
if t is None or t.bend == 0:
|
||||||
|
return None
|
||||||
|
if bone in ("right_arm", "left_arm"):
|
||||||
|
return LimbBend(-2, 10, t.bend, t.axis)
|
||||||
|
if bone in ("right_leg", "left_leg"):
|
||||||
|
return LimbBend(0, 12, t.bend, t.axis)
|
||||||
|
if bone == "body":
|
||||||
|
return LimbBend(0, 12, t.bend, t.axis, anchored_at_end=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def textured_points(bone, skin, spacing=0.5):
|
||||||
|
"""Points on a part's base and outer layer with their skin colours, in the part's own space.
|
||||||
|
|
||||||
|
Follows ModelPart.Cube: each face's corners and UV rectangle, and Polygon's corner-to-UV order.
|
||||||
|
Outer layer points are only kept where the skin has pixels there.
|
||||||
|
"""
|
||||||
|
(fx, fy, fz), (dx, dy, dz) = BOXES[bone]
|
||||||
|
pts, cols = [], []
|
||||||
|
for layer, (u, v) in enumerate(UV[bone]):
|
||||||
|
g = OVERLAY[bone] if layer else 0
|
||||||
|
f, gg, h = fx - g, fy - g, fz - g
|
||||||
|
i, j, k = fx + dx + g, fy + dy + g, fz + dz + g
|
||||||
|
c = [(f, gg, h), (i, gg, h), (i, j, h), (f, j, h), (f, gg, k), (i, gg, k), (i, j, k), (f, j, k)]
|
||||||
|
l, m, n, o = u, u + dz, u + dz + dx, u + dz + dx + dx
|
||||||
|
pp, q = u + dz + dx + dz, u + dz + dx + dz + dx
|
||||||
|
r, ss, t = v, v + dz, v + dz + dy
|
||||||
|
faces = [ # corners, (u1, v1, u2, v2)
|
||||||
|
((5, 4, 0, 1), (m, r, n, ss)), # DOWN: minY, the top in model space
|
||||||
|
((2, 3, 7, 6), (n, ss, o, r)), # UP: maxY, the bottom
|
||||||
|
((0, 4, 7, 3), (l, ss, m, t)), # WEST: -X, the player's right
|
||||||
|
((1, 0, 3, 2), (m, ss, n, t)), # NORTH: -Z, the front
|
||||||
|
((5, 1, 2, 6), (n, ss, pp, t)), # EAST: +X, the player's left
|
||||||
|
((4, 5, 6, 7), (pp, ss, q, t)), # SOUTH: +Z, the back
|
||||||
|
]
|
||||||
|
for corners, (u1, v1, u2, v2) in faces:
|
||||||
|
a, b, _, d = (np.array(c[x], float) for x in corners)
|
||||||
|
ns = max(2, int(np.ceil(np.linalg.norm(b - a) / spacing)) + 1)
|
||||||
|
nt = max(2, int(np.ceil(np.linalg.norm(d - a) / spacing)) + 1)
|
||||||
|
# Sample cell centres so every point falls inside one texture pixel.
|
||||||
|
sv, tv = np.meshgrid((np.arange(ns) + 0.5) / ns, (np.arange(nt) + 0.5) / nt, indexing="ij")
|
||||||
|
sv, tv = sv.ravel(), tv.ravel()
|
||||||
|
face = a + sv[:, None] * (b - a) + tv[:, None] * (d - a)
|
||||||
|
tu = np.clip(np.floor(u2 + sv * (u1 - u2)).astype(int), min(u1, u2), max(u1, u2) - 1)
|
||||||
|
tvv = np.clip(np.floor(v1 + tv * (v2 - v1)).astype(int), min(v1, v2), max(v1, v2) - 1)
|
||||||
|
rgba = skin[tvv, tu]
|
||||||
|
keep = rgba[:, 3] > 0 if layer else np.ones(len(face), bool)
|
||||||
|
pts.append(face[keep])
|
||||||
|
cols.append(rgba[keep, :3])
|
||||||
|
return np.concatenate(pts), np.concatenate(cols).astype(float) / 255
|
||||||
|
|
||||||
|
|
||||||
|
def surface_points(bone, spacing=0.5, overlay=True):
|
||||||
|
"""Points on the surface of a part's box (outer layer included), in the part's own space."""
|
||||||
|
(fx, fy, fz), (sx, sy, sz) = BOXES[bone]
|
||||||
|
g = OVERLAY[bone] if overlay else 0
|
||||||
|
lo = np.array([fx - g, fy - g, fz - g])
|
||||||
|
hi = np.array([fx + sx + g, fy + sy + g, fz + sz + g])
|
||||||
|
axes = [np.linspace(lo[i], hi[i], max(2, int(np.ceil((hi[i] - lo[i]) / spacing)) + 1)) for i in range(3)]
|
||||||
|
pts = []
|
||||||
|
for fixed in range(3):
|
||||||
|
a, b = [i for i in range(3) if i != fixed]
|
||||||
|
u, v = np.meshgrid(axes[a], axes[b], indexing="ij")
|
||||||
|
for side in (lo[fixed], hi[fixed]):
|
||||||
|
face = np.empty((u.size, 3))
|
||||||
|
face[:, a], face[:, b], face[:, fixed] = u.ravel(), v.ravel(), side
|
||||||
|
pts.append(face)
|
||||||
|
return np.unique(np.round(np.concatenate(pts), 4), axis=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Model:
|
||||||
|
"""Poses the player's parts. pose() returns model-space points for each part.
|
||||||
|
|
||||||
|
With a skin (an RGBA array), the points carry the skin's colours in self.colors, and the outer layer
|
||||||
|
only exists where the skin has pixels. Without one, parts are plain boxes including the outer layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, spacing=0.5, skin=None):
|
||||||
|
if skin is None:
|
||||||
|
self.local = {bone: surface_points(bone, spacing) for bone in PIVOTS}
|
||||||
|
self.colors = None
|
||||||
|
else:
|
||||||
|
textured = {bone: textured_points(bone, skin, spacing) for bone in PIVOTS}
|
||||||
|
self.local = {bone: pts for bone, (pts, _) in textured.items()}
|
||||||
|
self.colors = np.concatenate([cols for _, cols in textured.values()])
|
||||||
|
|
||||||
|
def part_frames(self, pose):
|
||||||
|
"""Each part's pivot, rotation and bend after PoseApplier.apply, in root space."""
|
||||||
|
frames = {}
|
||||||
|
for bone in PIVOTS:
|
||||||
|
t = pose.get(bone)
|
||||||
|
pivot = np.array(PIVOTS[bone], float)
|
||||||
|
rot = np.eye(3)
|
||||||
|
if t is not None:
|
||||||
|
pivot = pivot + [t.x, t.y, t.z]
|
||||||
|
rot = rotation_zyx(t.pitch, t.yaw, t.roll)
|
||||||
|
frames[bone] = [pivot, rot, limb_bend(bone, t)]
|
||||||
|
|
||||||
|
body_pivot, body_rot, body_bend = frames["body"]
|
||||||
|
if body_bend is not None and not body_bend.straight:
|
||||||
|
for bone in ("head", "right_arm", "left_arm"):
|
||||||
|
pivot, rot, bend = frames[bone]
|
||||||
|
local = body_rot.T @ (pivot - body_pivot)
|
||||||
|
moved = body_bend.bend_points(local[None, :])[0]
|
||||||
|
turn = axis_angle(body_bend.n, body_bend.angle_at(local[1]))
|
||||||
|
frames[bone] = [body_pivot + body_rot @ moved, body_rot @ turn @ body_rot.T @ rot, bend]
|
||||||
|
return frames
|
||||||
|
|
||||||
|
def pose(self, pose):
|
||||||
|
frames = self.part_frames(pose)
|
||||||
|
root = pose.get("root")
|
||||||
|
if root is not None:
|
||||||
|
r_rot = rotation_zyx(root.pitch, root.yaw, root.roll)
|
||||||
|
pivot = np.array([0, ROOT_PIVOT_Y, 0])
|
||||||
|
r_pos = np.array([root.x, root.y, root.z]) + pivot - r_rot @ pivot
|
||||||
|
else:
|
||||||
|
r_rot, r_pos = np.eye(3), np.zeros(3)
|
||||||
|
out = {}
|
||||||
|
for bone, (pivot, rot, bend) in frames.items():
|
||||||
|
p = self.local[bone]
|
||||||
|
if bend is not None and not bend.straight:
|
||||||
|
p = bend.bend_points(p)
|
||||||
|
out[bone] = (p @ rot.T + pivot) @ r_rot.T + r_pos
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- The rig the fitter works in -------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Moving every pivot freely lets parts drift apart, so the fitter uses a jointed rig instead and
|
||||||
|
# converts it to BoneTransforms: the legs hang from the hips, the torso's hip end sits on the hips,
|
||||||
|
# and the head and arms hang from the torso (PoseApplier then carries them along its bend).
|
||||||
|
|
||||||
|
RIG = (
|
||||||
|
["root_x", "root_y", "root_z", "root_pitch", "root_yaw", "root_roll", "hip_x", "hip_y", "hip_z"]
|
||||||
|
+ [f"body_{c}" for c in ("pitch", "yaw", "roll", "bend", "axis")]
|
||||||
|
+ [f"head_{c}" for c in ("pitch", "yaw", "roll")]
|
||||||
|
+ [f"{limb}_{c}" for limb in ("right_arm", "left_arm", "right_leg", "left_leg")
|
||||||
|
for c in ("pitch", "yaw", "roll", "bend", "axis")]
|
||||||
|
)
|
||||||
|
INDEX = {name: i for i, name in enumerate(RIG)}
|
||||||
|
|
||||||
|
|
||||||
|
def rig_to_pose(v):
|
||||||
|
g = lambda name: float(v[INDEX[name]])
|
||||||
|
hip = np.array([g("hip_x"), g("hip_y"), g("hip_z")])
|
||||||
|
body_rot = rotation_zyx(g("body_pitch"), g("body_yaw"), g("body_roll"))
|
||||||
|
# The torso's bottom middle (0, 12, 0) in its own space stays on the hips; a bend keeps it fixed.
|
||||||
|
hips_rest = np.array([0, 12, 0.0])
|
||||||
|
body_pivot = hips_rest + hip - body_rot @ hips_rest
|
||||||
|
pose = {
|
||||||
|
"root": Transform(g("root_x"), g("root_y"), g("root_z"), g("root_pitch"), g("root_yaw"), g("root_roll")),
|
||||||
|
"body": Transform(*body_pivot, g("body_pitch"), g("body_yaw"), g("body_roll"), g("body_bend"), g("body_axis")),
|
||||||
|
"head": Transform(*body_pivot, g("head_pitch"), g("head_yaw"), g("head_roll")),
|
||||||
|
}
|
||||||
|
for arm in ("right_arm", "left_arm"):
|
||||||
|
rest = np.array(PIVOTS[arm], float)
|
||||||
|
# Where the shoulder sits on the (unbent) tipped torso; PoseApplier adds the bend's carry.
|
||||||
|
offset = body_pivot + body_rot @ rest - rest
|
||||||
|
pose[arm] = Transform(*offset, *(g(f"{arm}_{c}") for c in ("pitch", "yaw", "roll", "bend", "axis")))
|
||||||
|
for leg in ("right_leg", "left_leg"):
|
||||||
|
pose[leg] = Transform(*hip, *(g(f"{leg}_{c}") for c in ("pitch", "yaw", "roll", "bend", "axis")))
|
||||||
|
return pose
|
||||||
|
|
||||||
|
|
||||||
|
def rest_rig():
|
||||||
|
return np.zeros(len(RIG))
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Draws the fitted pose's outline over the captured frames, to see where the fit is off.
|
||||||
|
|
||||||
|
.venv/bin/python overlay.py <name> [--ticks 0:13:3] [--views y000,y090,y045,top000]
|
||||||
|
|
||||||
|
Writes frames/<name>/overlay.png: rows are views, columns ticks. Red is where only our model is, blue
|
||||||
|
where only theirs is, and grey where both are.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from model import Model, rig_to_pose
|
||||||
|
from render import Camera, render_masks
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("name")
|
||||||
|
parser.add_argument("--ticks", default="0:13:3")
|
||||||
|
parser.add_argument("--views", default="y000,y090,y045,top000")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
base = Path("frames") / args.name
|
||||||
|
meta = json.loads((base / "views.json").read_text())
|
||||||
|
data = json.loads((base / "rig.json").read_text())
|
||||||
|
by_tick = {t["tick"]: np.array(t["rig"]) for t in data["ticks"]}
|
||||||
|
ticks = [t for t in range(*map(int, args.ticks.split(":"))) if t in by_tick]
|
||||||
|
views = args.views.split(",")
|
||||||
|
scale = 2
|
||||||
|
cams = {v: Camera(meta["views"][v], meta["size"], meta["aspect"], scale) for v in views}
|
||||||
|
model = Model(spacing=0.25)
|
||||||
|
|
||||||
|
tiles = []
|
||||||
|
for v in views:
|
||||||
|
row = []
|
||||||
|
for t in ticks:
|
||||||
|
pts = np.concatenate(list(model.pose(rig_to_pose(by_tick[t])).values()))
|
||||||
|
ours = render_masks(pts, data["calib"], {v: cams[v]})[v]
|
||||||
|
frame = Image.open(base / v / f"{t:04d}.png").reduce(scale)
|
||||||
|
theirs = np.asarray(frame)[:, :, 3] > 127
|
||||||
|
img = np.full(ours.shape + (3,), 30, np.uint8)
|
||||||
|
img[theirs & ours] = (150, 150, 150)
|
||||||
|
img[ours & ~theirs] = (230, 60, 60)
|
||||||
|
img[theirs & ~ours] = (60, 110, 240)
|
||||||
|
row.append(img)
|
||||||
|
tiles.append(row)
|
||||||
|
|
||||||
|
both = np.zeros(tiles[0][0].shape[:2], bool)
|
||||||
|
for row in tiles:
|
||||||
|
for img in row:
|
||||||
|
both |= img.sum(axis=2) > 90
|
||||||
|
ys, xs = np.nonzero(both)
|
||||||
|
y0, y1, x0, x1 = max(ys.min() - 4, 0), ys.max() + 4, max(xs.min() - 4, 0), xs.max() + 4
|
||||||
|
sheet = np.concatenate([np.concatenate([img[y0:y1, x0:x1] for img in row], axis=1) for row in tiles], axis=0)
|
||||||
|
Image.fromarray(sheet).save(base / "overlay.png")
|
||||||
|
print("wrote", base / "overlay.png", "ticks", ticks)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Renders model points through the cameras capture.py used, and scores them against captured masks."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from scipy.ndimage import distance_transform_edt
|
||||||
|
|
||||||
|
# Model space (Y down, facing -Z) to the viewer's world (Y up, facing +Z): Minecraft's scale(-1, -1, 1)
|
||||||
|
# after turning the player 180 degrees to face south.
|
||||||
|
FLIP = np.diag([1.0, -1.0, -1.0])
|
||||||
|
|
||||||
|
|
||||||
|
class Camera:
|
||||||
|
"""A three.js PerspectiveCamera placed with lookAt, as viewer.js places it."""
|
||||||
|
|
||||||
|
def __init__(self, view, size, aspect, scale):
|
||||||
|
yaw, pitch = np.radians(view["yaw"]), np.radians(view["pitch"])
|
||||||
|
target = np.array(view["target"], float)
|
||||||
|
d = view["distance"]
|
||||||
|
self.position = target + d * np.array([np.cos(pitch) * np.sin(yaw), np.sin(pitch), np.cos(pitch) * np.cos(yaw)])
|
||||||
|
z = self.position - target
|
||||||
|
z /= np.linalg.norm(z)
|
||||||
|
x = np.cross([0, 1, 0], z)
|
||||||
|
x /= np.linalg.norm(x)
|
||||||
|
y = np.cross(z, x)
|
||||||
|
self.rotation = np.stack([x, y, z]) # world to camera
|
||||||
|
self.f = 1 / np.tan(np.radians(view["fov"]) / 2)
|
||||||
|
self.aspect = aspect
|
||||||
|
self.width, self.height = size[0] // scale, size[1] // scale
|
||||||
|
|
||||||
|
def project(self, world):
|
||||||
|
c = (world - self.position) @ self.rotation.T
|
||||||
|
depth = -c[:, 2]
|
||||||
|
nx = self.f / self.aspect * c[:, 0] / depth
|
||||||
|
ny = self.f * c[:, 1] / depth
|
||||||
|
return np.stack([(nx + 1) / 2 * self.width, (1 - ny) / 2 * self.height], axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def to_world(points, calib):
|
||||||
|
"""calib = (scale, tx, ty, tz): where the viewer puts the model."""
|
||||||
|
return calib[0] * points @ FLIP.T + np.asarray(calib[1:4])
|
||||||
|
|
||||||
|
|
||||||
|
def splat(px, width, height):
|
||||||
|
"""A mask covering every projected point, 2x2 pixels each so the surface has no holes."""
|
||||||
|
mask = np.zeros((height, width), bool)
|
||||||
|
x = np.floor(px[:, 0] - 0.5).astype(int)
|
||||||
|
y = np.floor(px[:, 1] - 0.5).astype(int)
|
||||||
|
for dx in (0, 1):
|
||||||
|
for dy in (0, 1):
|
||||||
|
xs, ys = x + dx, y + dy
|
||||||
|
ok = (xs >= 0) & (xs < width) & (ys >= 0) & (ys < height)
|
||||||
|
mask[ys[ok], xs[ok]] = True
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
def color_features(rgb):
|
||||||
|
"""Lighting-tolerant colour: hue and saturation as opponent channels divided by brightness, plus
|
||||||
|
log brightness. Shading scales all three channels, which leaves the first two alone."""
|
||||||
|
lum = rgb.mean(axis=-1)
|
||||||
|
o1 = rgb[..., 0] - rgb[..., 1]
|
||||||
|
o2 = (rgb[..., 0] + rgb[..., 1]) / 2 - rgb[..., 2]
|
||||||
|
return np.stack([o1 / (lum + 0.05), o2 / (lum + 0.05), np.log(lum + 0.05)], axis=-1)
|
||||||
|
|
||||||
|
|
||||||
|
class Target:
|
||||||
|
"""One captured view of one tick, downscaled, with the distance maps the score needs."""
|
||||||
|
|
||||||
|
def __init__(self, mask, rgb=None):
|
||||||
|
self.mask = mask
|
||||||
|
self.outside = distance_transform_edt(~mask)
|
||||||
|
self.ys, self.xs = np.nonzero(mask)
|
||||||
|
self.features = None if rgb is None else color_features(rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def downscale(alpha, scale):
|
||||||
|
h, w = alpha.shape
|
||||||
|
blocks = alpha[: h // scale * scale, : w // scale * scale].reshape(h // scale, scale, w // scale, scale)
|
||||||
|
return blocks.mean(axis=(1, 3)) > 127
|
||||||
|
|
||||||
|
|
||||||
|
def downscale_rgb(rgba, scale):
|
||||||
|
"""Mean colour of the covered pixels in each block (0-1)."""
|
||||||
|
h, w = rgba.shape[:2]
|
||||||
|
blocks = rgba[: h // scale * scale, : w // scale * scale].astype(float)
|
||||||
|
blocks = blocks.reshape(h // scale, scale, w // scale, scale, 4)
|
||||||
|
alpha = blocks[..., 3:] / 255
|
||||||
|
return (blocks[..., :3] / 255 * alpha).sum(axis=(1, 3)) / np.maximum(alpha.sum(axis=(1, 3)), 1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
class Capture:
|
||||||
|
"""The frames and cameras of one capture."""
|
||||||
|
|
||||||
|
def __init__(self, directory, scale=4, views=None):
|
||||||
|
self.dir = Path(directory)
|
||||||
|
self.meta = json.loads((self.dir / "views.json").read_text())
|
||||||
|
self.scale = scale
|
||||||
|
self.names = views or list(self.meta["views"])
|
||||||
|
self.cameras = {n: Camera(self.meta["views"][n], self.meta["size"], self.meta["aspect"], scale) for n in self.names}
|
||||||
|
|
||||||
|
def targets(self, tick):
|
||||||
|
out = {}
|
||||||
|
for n in self.names:
|
||||||
|
rgba = np.asarray(Image.open(self.dir / n / f"{tick:04d}.png"))
|
||||||
|
out[n] = Target(downscale(rgba[:, :, 3], self.scale), downscale_rgb(rgba, self.scale))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# How much a unit of colour difference counts against a pixel of outline distance, and how much
|
||||||
|
# brightness counts next to hue and saturation.
|
||||||
|
COLOR_WEIGHT = 0.5
|
||||||
|
BRIGHTNESS_WEIGHT = 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def score(points, calib, cameras, targets, per_view=False, colors=None, color_views=None, one_way=False,
|
||||||
|
color_weight=None):
|
||||||
|
"""Mean outline distance in pixels, both ways (our points outside theirs and theirs outside ours),
|
||||||
|
plus, with colours, how different the colours are where both outlines cover a pixel.
|
||||||
|
|
||||||
|
one_way only counts our points outside theirs, for fitting some of the parts: the rest of their
|
||||||
|
outline is still unexplained, and shouldn't pull the parts being fitted towards it."""
|
||||||
|
world = to_world(points, calib)
|
||||||
|
total = {}
|
||||||
|
for name, cam in cameras.items():
|
||||||
|
t = targets[name]
|
||||||
|
px = cam.project(world)
|
||||||
|
xi = np.clip(px[:, 0].astype(int), 0, cam.width - 1)
|
||||||
|
yi = np.clip(px[:, 1].astype(int), 0, cam.height - 1)
|
||||||
|
ours_out = t.outside[yi, xi].mean()
|
||||||
|
if colors is not None and t.features is not None and (color_views is None or name in color_views):
|
||||||
|
depth = -((world - cam.position) @ cam.rotation.T)[:, 2]
|
||||||
|
image, mask = splat_color(px, depth, colors, cam.width, cam.height)
|
||||||
|
both = mask & t.mask
|
||||||
|
diff = np.abs(color_features(image[both]) - t.features[both])
|
||||||
|
color = (diff[:, 0] + diff[:, 1] + BRIGHTNESS_WEIGHT * diff[:, 2]).mean() if both.any() else 0
|
||||||
|
else:
|
||||||
|
mask = splat(px, cam.width, cam.height)
|
||||||
|
color = 0
|
||||||
|
theirs_out = 0 if one_way or not len(t.ys) else distance_transform_edt(~mask)[t.ys, t.xs].mean()
|
||||||
|
total[name] = ours_out + theirs_out + (COLOR_WEIGHT if color_weight is None else color_weight) * color
|
||||||
|
return total if per_view else sum(total.values()) / len(total)
|
||||||
|
|
||||||
|
|
||||||
|
def render_masks(points, calib, cameras):
|
||||||
|
world = to_world(points, calib)
|
||||||
|
return {n: splat(c.project(world), c.width, c.height) for n, c in cameras.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def splat_color(px, depth, colors, width, height):
|
||||||
|
"""Colour image of the nearest point in each pixel (2x2 pixels per point), and its coverage mask."""
|
||||||
|
order = np.argsort(-depth) # far to near, so nearer points are written last and win
|
||||||
|
x = np.floor(px[order, 0] - 0.5).astype(int)
|
||||||
|
y = np.floor(px[order, 1] - 0.5).astype(int)
|
||||||
|
# All four pixels of every point in one write; a write per offset would let far points overwrite
|
||||||
|
# pixels near points filled at another offset.
|
||||||
|
xs = np.stack([x, x + 1, x, x + 1], axis=1).ravel()
|
||||||
|
ys = np.stack([y, y, y + 1, y + 1], axis=1).ravel()
|
||||||
|
cs = np.repeat(colors[order], 4, axis=0)
|
||||||
|
ok = (xs >= 0) & (xs < width) & (ys >= 0) & (ys < height)
|
||||||
|
image = np.zeros((height, width, 3))
|
||||||
|
mask = np.zeros((height, width), bool)
|
||||||
|
image[ys[ok], xs[ok]] = cs[ok]
|
||||||
|
mask[ys[ok], xs[ok]] = True
|
||||||
|
return image, mask
|
||||||
|
|
||||||
|
|
||||||
|
def render_colors(points, colors, calib, cameras):
|
||||||
|
world = to_world(points, calib)
|
||||||
|
out = {}
|
||||||
|
for n, c in cameras.items():
|
||||||
|
depth = -((world - c.position) @ c.rotation.T)[:, 2]
|
||||||
|
out[n] = splat_color(c.project(world), depth, colors, c.width, c.height)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
cma==4.5.0
|
||||||
|
numpy==2.5.3
|
||||||
|
pillow==12.3.0
|
||||||
|
playwright==1.63.0
|
||||||
|
scipy==1.18.1
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Tiles frames into a contact sheet: rows are views, columns are ticks, cropped to the player."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
name, views, ticks = sys.argv[1], sys.argv[2].split(","), range(*map(int, sys.argv[3].split(":")))
|
||||||
|
out = sys.argv[4] if len(sys.argv) > 4 else "sheet.png"
|
||||||
|
frames = [[Image.open(Path("frames") / name / v / f"{t:04d}.png") for t in ticks] for v in views]
|
||||||
|
alpha = np.maximum.reduce([np.asarray(f)[:, :, 3] for row in frames for f in row])
|
||||||
|
ys, xs = np.nonzero(alpha)
|
||||||
|
box = (xs.min() - 4, ys.min() - 4, xs.max() + 4, ys.max() + 4)
|
||||||
|
w, h = box[2] - box[0], box[3] - box[1]
|
||||||
|
sheet = Image.new("RGBA", (w * len(ticks), h * len(views)), (40, 40, 52, 255))
|
||||||
|
for r, row in enumerate(frames):
|
||||||
|
for c, f in enumerate(row):
|
||||||
|
sheet.alpha_composite(f.crop(box), (c * w, r * h))
|
||||||
|
sheet.save(out)
|
||||||
|
print(sheet.size)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Fetches a player's skin texture from Mojang, the same one the store viewer shows for that username.
|
||||||
|
|
||||||
|
.venv/bin/python skin.py <username> [out.png]
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def get(url):
|
||||||
|
# curl uses the system's certificate store, which python.org's Python doesn't.
|
||||||
|
return subprocess.run(["curl", "-sSf", url], capture_output=True, check=True).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(username):
|
||||||
|
"""Returns (png bytes, slim arms)."""
|
||||||
|
uuid = json.loads(get(f"https://api.mojang.com/users/profiles/minecraft/{username}"))["id"]
|
||||||
|
profile = json.loads(get(f"https://sessionserver.mojang.com/session/minecraft/profile/{uuid}"))
|
||||||
|
prop = next(p for p in profile["properties"] if p["name"] == "textures")
|
||||||
|
skin = json.loads(base64.b64decode(prop["value"]))["textures"]["SKIN"]
|
||||||
|
return get(skin["url"]), skin.get("metadata", {}).get("model") == "slim"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
png, slim = fetch(sys.argv[1])
|
||||||
|
out = sys.argv[2] if len(sys.argv) > 2 else f"skins/{sys.argv[1]}.png"
|
||||||
|
open(out, "wb").write(png)
|
||||||
|
print(out, "slim" if slim else "wide")
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Left/right symmetry of a measured loop: how lopsided it is, and a symmetric version of it.
|
||||||
|
|
||||||
|
.venv/bin/python symmetry.py <name> [--rig path.json]
|
||||||
|
|
||||||
|
A mirror swaps the left and right limbs and flips every sideways channel (the hips' x, yaws, rolls and
|
||||||
|
bend directions). A pose that sways once per loop should be the mirror of itself half a loop later;
|
||||||
|
one with no sideways motion, the mirror of itself at the same moment. Whatever breaks that is
|
||||||
|
lopsidedness the fit picked up (one leg bent more, one side swinging further), not the dance.
|
||||||
|
|
||||||
|
symmetrize() averages each tick with the mirror of its partner, which keeps the sway and removes the
|
||||||
|
rest. fit.py --symmetric uses it.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from model import INDEX, RIG
|
||||||
|
|
||||||
|
SIDES = {"right_arm": "left_arm", "left_arm": "right_arm", "right_leg": "left_leg", "left_leg": "right_leg"}
|
||||||
|
ANGLES = np.array([not name.endswith(("_x", "_y", "_z")) for name in RIG])
|
||||||
|
|
||||||
|
|
||||||
|
def wrap(a):
|
||||||
|
return (a + np.pi) % (2 * np.pi) - np.pi
|
||||||
|
|
||||||
|
|
||||||
|
def mirror(rig):
|
||||||
|
"""The pose reflected through the player's middle (x -> -x). Rotations about x keep their angle and
|
||||||
|
those about y and z flip (S Rz Ry Rx S with S = diag(-1, 1, 1)); a bend direction, measured around
|
||||||
|
the limb from its front, flips too."""
|
||||||
|
out = np.empty_like(rig)
|
||||||
|
for i, name in enumerate(RIG):
|
||||||
|
part, _, channel = name.rpartition("_")
|
||||||
|
source = next((f"{SIDES[p]}{name[len(p):]}" for p in SIDES if name.startswith(p)), name)
|
||||||
|
value = rig[INDEX[source]]
|
||||||
|
out[i] = -value if channel in ("x", "yaw", "roll", "axis") else value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def shifted(rigs, fraction):
|
||||||
|
"""Each channel of a loop evaluated `fraction` of a loop later, between samples too, through its
|
||||||
|
Fourier series. Angles that go round whole turns in a loop keep doing so."""
|
||||||
|
rigs = np.asarray(rigs, float)
|
||||||
|
n = len(rigs)
|
||||||
|
out = np.empty_like(rigs)
|
||||||
|
k = np.arange(n // 2 + 1)
|
||||||
|
for i in range(rigs.shape[1]):
|
||||||
|
values = np.unwrap(rigs[:, i]) if ANGLES[i] else rigs[:, i]
|
||||||
|
turns = 0.0
|
||||||
|
if ANGLES[i]:
|
||||||
|
turns = values[-1] + wrap(values[0] - values[-1]) - values[0]
|
||||||
|
ramp = turns * np.arange(n) / n
|
||||||
|
spectrum = np.fft.rfft(values - ramp) * np.exp(2j * np.pi * k * fraction)
|
||||||
|
out[:, i] = np.fft.irfft(spectrum, n) + ramp + turns * fraction
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def partners(rigs, sway):
|
||||||
|
"""The mirrored partner of every tick: half a loop on for a pose that sways once per loop."""
|
||||||
|
rigs = np.asarray(rigs, float)
|
||||||
|
source = shifted(rigs, 0.5) if sway else rigs
|
||||||
|
return np.array([mirror(r) for r in source])
|
||||||
|
|
||||||
|
|
||||||
|
def difference(a, b):
|
||||||
|
d = a - b
|
||||||
|
return np.where(ANGLES, wrap(d), d)
|
||||||
|
|
||||||
|
|
||||||
|
def symmetrize(rigs, sway):
|
||||||
|
"""Each tick averaged with its mirrored partner (the short way round for angles)."""
|
||||||
|
rigs = np.asarray(rigs, float)
|
||||||
|
return rigs - difference(rigs, partners(rigs, sway)) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def lopsidedness(rigs, sway):
|
||||||
|
"""Per channel: how far each tick is from its mirrored partner, as (mean, rms) over the loop. The
|
||||||
|
mean is a steady lean to one side; the rms includes one side swinging further than the other."""
|
||||||
|
d = difference(np.asarray(rigs, float), partners(rigs, sway))
|
||||||
|
return d.mean(axis=0), np.sqrt((d ** 2).mean(axis=0))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("name")
|
||||||
|
parser.add_argument("--rig")
|
||||||
|
args = parser.parse_args()
|
||||||
|
path = Path(args.rig) if args.rig else Path("frames") / args.name / "rig.json"
|
||||||
|
rigs = np.array([t["rig"] for t in json.loads(path.read_text())["ticks"]])
|
||||||
|
|
||||||
|
# Which pairing fits: a sway once per loop, or none.
|
||||||
|
for sway in (True, False):
|
||||||
|
_, rms = lopsidedness(rigs, sway)
|
||||||
|
angle_rms = np.degrees(np.sqrt((rms[ANGLES] ** 2).mean()))
|
||||||
|
offset_rms = np.sqrt((rms[~ANGLES] ** 2).mean())
|
||||||
|
print(f"{'sway once per loop' if sway else 'no sway':18s}: rms {angle_rms:.1f} deg, {offset_rms:.2f} px")
|
||||||
|
|
||||||
|
sway = True
|
||||||
|
mean, rms = lopsidedness(rigs, sway)
|
||||||
|
print("\nMost lopsided channels (tick vs its mirrored partner half a loop on):")
|
||||||
|
order = np.argsort(-np.where(ANGLES, np.degrees(rms), rms * 5))
|
||||||
|
for i in order[:14]:
|
||||||
|
unit, f = ("deg", np.degrees) if ANGLES[i] else ("px", lambda v: v)
|
||||||
|
print(f" {RIG[i]:18s} steady {f(mean[i]):+6.1f} {unit}, rms {f(rms[i]):5.1f} {unit}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""End-to-end check of measure.py on frames with a known answer: our own twerk test animation, rendered
|
||||||
|
by model.py as if it were a capture, then measured like one.
|
||||||
|
|
||||||
|
.venv/bin/python synth_test.py make <capture to copy cameras from> [ticks] # writes frames/synth
|
||||||
|
.venv/bin/python measure.py synth --ticks 0:4
|
||||||
|
.venv/bin/python synth_test.py check
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from scipy.spatial import cKDTree
|
||||||
|
|
||||||
|
from model import INDEX, Model, rest_rig, rig_to_pose
|
||||||
|
from render import Camera, render_colors
|
||||||
|
|
||||||
|
# Where the fake viewer puts the model: Minecraft's player scale, feet 25 units below the origin.
|
||||||
|
CALIB = (0.9375, 0, -1.5, 0)
|
||||||
|
SKIN = "Steve"
|
||||||
|
OUT = Path("frames/synth")
|
||||||
|
|
||||||
|
|
||||||
|
def twerk(t):
|
||||||
|
"""TestAnimations.twerk, written in rig terms."""
|
||||||
|
up = 0.5 * (1 - np.cos(t / 8 * 2 * np.pi))
|
||||||
|
thigh, knee, splay, tilt, arch = -1.1 + 0.15 * up, 1.75 - 0.3 * up, 0.55, 1.4, 0.4 + 0.3 * up
|
||||||
|
drop = 12 - 6 * (np.cos(thigh) + np.cos(thigh + knee)) * np.cos(splay)
|
||||||
|
back = -6 * (np.sin(thigh) + np.sin(thigh + knee))
|
||||||
|
chest = tilt - arch
|
||||||
|
v = rest_rig()
|
||||||
|
s = lambda name, value: v.__setitem__(INDEX[name], value)
|
||||||
|
s("hip_y", drop), s("hip_z", back)
|
||||||
|
s("body_pitch", tilt), s("body_bend", arch), s("body_axis", np.pi)
|
||||||
|
s("head_pitch", tilt - chest * 0.5)
|
||||||
|
for arm, roll in (("right_arm", 0.3), ("left_arm", -0.3)):
|
||||||
|
s(f"{arm}_pitch", tilt - chest * 1.1), s(f"{arm}_roll", roll), s(f"{arm}_bend", 0.5)
|
||||||
|
for leg, sign in (("right_leg", 1), ("left_leg", -1)):
|
||||||
|
s(f"{leg}_pitch", thigh), s(f"{leg}_roll", sign * splay), s(f"{leg}_bend", knee)
|
||||||
|
s(f"{leg}_axis", np.pi - sign * 0.35)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def skin():
|
||||||
|
return np.asarray(Image.open(f"skins/{SKIN}.png").convert("RGBA"))
|
||||||
|
|
||||||
|
|
||||||
|
def make(source, ticks):
|
||||||
|
meta = json.loads((Path("frames") / source / "views.json").read_text())
|
||||||
|
model = Model(spacing=0.08, skin=skin())
|
||||||
|
cams = {n: Camera(v, meta["size"], meta["aspect"], 1) for n, v in meta["views"].items()}
|
||||||
|
for tick in range(ticks):
|
||||||
|
pts = np.concatenate(list(model.pose(rig_to_pose(twerk(tick))).values()))
|
||||||
|
for name, (image, mask) in render_colors(pts, model.colors, CALIB, cams).items():
|
||||||
|
(OUT / name).mkdir(parents=True, exist_ok=True)
|
||||||
|
rgba = np.concatenate([image * 255, mask[..., None] * 255], axis=-1).astype(np.uint8)
|
||||||
|
Image.fromarray(rgba).save(OUT / name / f"{tick:04d}.png")
|
||||||
|
meta |= {"url": "synthetic twerk", "skin": SKIN, "ticks": ticks, "loop_ticks": None}
|
||||||
|
(OUT / "views.json").write_text(json.dumps(meta, indent=2))
|
||||||
|
print(f"wrote {ticks} ticks to {OUT}")
|
||||||
|
|
||||||
|
|
||||||
|
def check():
|
||||||
|
data = json.loads((OUT / "rig.json").read_text())
|
||||||
|
print("calibration:", np.round(data["calib"], 3), "true:", CALIB)
|
||||||
|
model = Model(skin=skin())
|
||||||
|
for entry in data["ticks"]:
|
||||||
|
truth, got = model.pose(rig_to_pose(twerk(entry["tick"]))), model.pose(rig_to_pose(np.array(entry["rig"])))
|
||||||
|
# The fitted pose sits in the fitted calibration's frame; compare where the viewer shows them.
|
||||||
|
to = lambda p, c: c[0] * p + np.asarray(c[1:]) * [1, -1, -1]
|
||||||
|
parts = []
|
||||||
|
for part in truth:
|
||||||
|
a, b = to(truth[part], CALIB), to(got[part], data["calib"])
|
||||||
|
parts.append((part, np.linalg.norm(a - b, axis=1).mean(), cKDTree(b).query(a)[0].mean()))
|
||||||
|
print(f"tick {entry['tick']}: score {entry['score']:.3f} "
|
||||||
|
+ " ".join(f"{p} {d:.2f}/{s:.2f}" for p, d, s in parts))
|
||||||
|
print("(per part: mean point-for-point / shape-only distance, in model pixels)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if sys.argv[1] == "make":
|
||||||
|
make(sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 else 4)
|
||||||
|
else:
|
||||||
|
check()
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Injected before the page loads. It puts the page on a clock we step by hand, and uses three.js's
|
||||||
|
// devtools hook to find the renderer and the camera it draws with. Only the clock and the camera are
|
||||||
|
// touched: the frames are measured from pixels, never from the scene's animation data.
|
||||||
|
(() => {
|
||||||
|
let now = 0;
|
||||||
|
let callbacks = [];
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
const realDateNow = Date.now.bind(Date);
|
||||||
|
const epoch = realDateNow();
|
||||||
|
performance.now = () => now;
|
||||||
|
Date.now = () => epoch + now;
|
||||||
|
window.requestAnimationFrame = (cb) => {
|
||||||
|
const id = nextId++;
|
||||||
|
callbacks.push({ id, cb });
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
window.cancelAnimationFrame = (id) => {
|
||||||
|
callbacks = callbacks.filter((c) => c.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The camera placement to force on every render: a point the camera orbits, its distance, and the
|
||||||
|
// yaw/pitch it looks from (degrees; yaw 0 looks at the model's front).
|
||||||
|
let view = null;
|
||||||
|
let lastFrame = null;
|
||||||
|
|
||||||
|
const capture = {
|
||||||
|
renderers: [],
|
||||||
|
camera: null,
|
||||||
|
/** Advances the clock by ms and runs one animation frame. Returns the frame as a PNG data URL. */
|
||||||
|
step(ms, grab) {
|
||||||
|
now += ms;
|
||||||
|
lastFrame = null;
|
||||||
|
const due = callbacks;
|
||||||
|
callbacks = [];
|
||||||
|
for (const { cb } of due) {
|
||||||
|
try { cb(now); } catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
return grab ? lastFrame : null;
|
||||||
|
},
|
||||||
|
time() { return now; },
|
||||||
|
setView(v) { view = v; },
|
||||||
|
camera_info() {
|
||||||
|
const cam = capture.camera;
|
||||||
|
return cam && { fov: cam.fov, aspect: cam.aspect, near: cam.near, far: cam.far,
|
||||||
|
position: cam.position.toArray(), quaternion: cam.quaternion.toArray() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
window.__capture = capture;
|
||||||
|
|
||||||
|
function place(camera) {
|
||||||
|
const toRad = Math.PI / 180;
|
||||||
|
const yaw = view.yaw * toRad;
|
||||||
|
const pitch = view.pitch * toRad;
|
||||||
|
const [tx, ty, tz] = view.target;
|
||||||
|
camera.position.set(
|
||||||
|
tx + view.distance * Math.cos(pitch) * Math.sin(yaw),
|
||||||
|
ty + view.distance * Math.sin(pitch),
|
||||||
|
tz + view.distance * Math.cos(pitch) * Math.cos(yaw));
|
||||||
|
camera.up.set(0, 1, 0);
|
||||||
|
camera.lookAt(tx, ty, tz);
|
||||||
|
if (view.fov) {
|
||||||
|
camera.fov = view.fov;
|
||||||
|
}
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
camera.updateMatrixWorld(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hook = new EventTarget();
|
||||||
|
hook.addEventListener('observe', (event) => {
|
||||||
|
const obj = event.detail;
|
||||||
|
if (obj && obj.isWebGLRenderer && !capture.renderers.includes(obj)) {
|
||||||
|
capture.renderers.push(obj);
|
||||||
|
const render = obj.render.bind(obj);
|
||||||
|
obj.render = (scene, camera) => {
|
||||||
|
capture.camera = camera;
|
||||||
|
if (view) {
|
||||||
|
place(camera);
|
||||||
|
}
|
||||||
|
const result = render(scene, camera);
|
||||||
|
// Read the drawing buffer in the same task as the draw, before the browser clears it.
|
||||||
|
lastFrame = obj.domElement.toDataURL('image/png');
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.__THREE_DEVTOOLS__ = hook;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Converts Emotecraft's binary .emotecraft files to the Emotecraft JSON our EmotecraftLoader reads.
|
||||||
|
|
||||||
|
python3 convert.py <file.emotecraft>... [--out-dir DIR]
|
||||||
|
|
||||||
|
Writes <id>.json next to each file (or into DIR), with the id made from the file's name, and prints
|
||||||
|
what each file holds. Standard library only.
|
||||||
|
|
||||||
|
The binary is Emotecraft's network packet (EmotePacket): an int version, a purpose byte and a count of
|
||||||
|
sub-packets, each an id byte, a version byte, an int size and its data. Sub-packet 0 is the animation
|
||||||
|
in player-animator's legacy format (PlayerAnimationLibrary's LegacyAnimationBinary), whose values are
|
||||||
|
the same raw values Emotecraft JSON holds (radians, absolute pivots, the whole player in blocks), so
|
||||||
|
they're written out as they are with "degrees": false. Sub-packet 17 is the name, description and
|
||||||
|
author (header version 1 and up). Anything else (the icon, the scale channels, easing arguments) is left out, since our loader
|
||||||
|
doesn't read it.
|
||||||
|
|
||||||
|
One thing moves: in the legacy format the whole-player part "body" also carries the torso's bend.
|
||||||
|
In JSON, "body" is only the whole player, so its bend goes to "torso", as PlayerAnimationLibrary does.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ANIMATION, HEADER = 0, 17
|
||||||
|
AXES = ["x", "y", "z", "pitch", "yaw", "roll"]
|
||||||
|
BEND_AXES = ["axis", "bend"]
|
||||||
|
SCALE_AXES = ["scaleX", "scaleY", "scaleZ"]
|
||||||
|
# The fixed part order before version 2 named its parts.
|
||||||
|
V1_PARTS = ["head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"]
|
||||||
|
# PlayerAnimationLibrary's EasingType ids. Our Easing.fromName reads the same names.
|
||||||
|
EASINGS = {
|
||||||
|
0: "LINEAR", 1: "CONSTANT", 6: "EASEINSINE", 7: "EASEOUTSINE", 8: "EASEINOUTSINE",
|
||||||
|
9: "EASEINCUBIC", 10: "EASEOUTCUBIC", 11: "EASEINOUTCUBIC", 12: "EASEINQUAD", 13: "EASEOUTQUAD",
|
||||||
|
14: "EASEINOUTQUAD", 15: "EASEINQUART", 16: "EASEOUTQUART", 17: "EASEINOUTQUART",
|
||||||
|
18: "EASEINQUINT", 19: "EASEOUTQUINT", 20: "EASEINOUTQUINT", 21: "EASEINEXPO", 22: "EASEOUTEXPO",
|
||||||
|
23: "EASEINOUTEXPO", 24: "EASEINCIRC", 25: "EASEOUTCIRC", 26: "EASEINOUTCIRC", 27: "EASEINBACK",
|
||||||
|
28: "EASEOUTBACK", 29: "EASEINOUTBACK", 30: "EASEINELASTIC", 31: "EASEOUTELASTIC",
|
||||||
|
32: "EASEINOUTELASTIC", 33: "EASEINBOUNCE", 34: "EASEOUTBOUNCE", 35: "EASEINOUTBOUNCE",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Reader:
|
||||||
|
def __init__(self, data):
|
||||||
|
self.data, self.pos = data, 0
|
||||||
|
|
||||||
|
def take(self, fmt):
|
||||||
|
values = struct.unpack_from(">" + fmt, self.data, self.pos)
|
||||||
|
self.pos += struct.calcsize(">" + fmt)
|
||||||
|
return values[0] if len(values) == 1 else values
|
||||||
|
|
||||||
|
def bool(self):
|
||||||
|
return self.take("b") != 0
|
||||||
|
|
||||||
|
def string(self):
|
||||||
|
size = self.take("i")
|
||||||
|
text = self.data[self.pos:self.pos + size].decode("utf-8")
|
||||||
|
self.pos += size
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def read_channel(r, version, keyframe_size):
|
||||||
|
"""[(tick, value, easing id)], or None when the channel is off."""
|
||||||
|
if version >= 2:
|
||||||
|
enabled, count = r.bool(), r.take("i")
|
||||||
|
else:
|
||||||
|
count = r.take("i")
|
||||||
|
enabled = count >= 0
|
||||||
|
if not enabled:
|
||||||
|
r.pos += max(count, 0) * keyframe_size
|
||||||
|
return None
|
||||||
|
keys = []
|
||||||
|
for _ in range(count):
|
||||||
|
start = r.pos
|
||||||
|
tick, value, easing = r.take("ifb")
|
||||||
|
keys.append((tick, value, easing))
|
||||||
|
r.pos = start + keyframe_size
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def read_part(r, name, version, keyframe_size):
|
||||||
|
channels = {axis: read_channel(r, version, keyframe_size) for axis in AXES}
|
||||||
|
# Every part but the head (and held items) has bend channels.
|
||||||
|
if name not in ("head", "left_item", "right_item", "leftItem", "rightItem"):
|
||||||
|
for axis in BEND_AXES:
|
||||||
|
channels[axis] = read_channel(r, version, keyframe_size)
|
||||||
|
if version >= 3:
|
||||||
|
for axis in SCALE_AXES:
|
||||||
|
channels[axis] = read_channel(r, version, keyframe_size)
|
||||||
|
return {axis: keys for axis, keys in channels.items() if keys}
|
||||||
|
|
||||||
|
|
||||||
|
def read_animation(data, version):
|
||||||
|
r = Reader(data)
|
||||||
|
r.take("i") # the tick the packet was sent at
|
||||||
|
begin, end, stop = r.take("iii")
|
||||||
|
loop = r.bool()
|
||||||
|
return_tick = r.take("i")
|
||||||
|
ease_before = r.bool()
|
||||||
|
r.bool() # a tag nothing reads
|
||||||
|
keyframe_size = r.take("b")
|
||||||
|
parts = {}
|
||||||
|
if version >= 2:
|
||||||
|
for _ in range(r.take("i")):
|
||||||
|
name = r.string()
|
||||||
|
parts[name] = read_part(r, name, version, keyframe_size)
|
||||||
|
else:
|
||||||
|
for name in V1_PARTS:
|
||||||
|
parts[name] = read_part(r, name, version, keyframe_size)
|
||||||
|
return {"begin": begin, "end": end, "stop": stop, "loop": loop, "return": return_tick,
|
||||||
|
"ease_before": ease_before, "parts": {n: p for n, p in parts.items() if p}}
|
||||||
|
|
||||||
|
|
||||||
|
def plain_text(component):
|
||||||
|
"""The header's strings are Minecraft text components in JSON: a quoted string, or an object
|
||||||
|
with "text", or a translation with a "fallback"."""
|
||||||
|
try:
|
||||||
|
value = json.loads(component)
|
||||||
|
except ValueError:
|
||||||
|
return component
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value.get("text") or value.get("fallback") or value.get("translate", "")
|
||||||
|
return str(value) if value is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
def read_file(path):
|
||||||
|
r = Reader(Path(path).read_bytes())
|
||||||
|
r.take("i") # network version
|
||||||
|
r.take("b") # purpose
|
||||||
|
info, animation, skipped = {}, None, []
|
||||||
|
for _ in range(r.take("B")):
|
||||||
|
packet, version, size = r.take("bbi")
|
||||||
|
data = r.data[r.pos:r.pos + size]
|
||||||
|
r.pos += size
|
||||||
|
if packet == ANIMATION:
|
||||||
|
animation = read_animation(data, version)
|
||||||
|
elif packet == HEADER:
|
||||||
|
h = Reader(data)
|
||||||
|
info = {key: plain_text(h.string()) for key in ("name", "description", "author")}
|
||||||
|
else:
|
||||||
|
skipped.append(packet)
|
||||||
|
if animation is None:
|
||||||
|
raise ValueError(f"{path}: no legacy animation sub-packet (maybe the newer format)")
|
||||||
|
return info, animation, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def to_json(info, animation):
|
||||||
|
parts = {name: dict(channels) for name, channels in animation["parts"].items()}
|
||||||
|
body = parts.get("body", {})
|
||||||
|
for axis in BEND_AXES:
|
||||||
|
if axis in body:
|
||||||
|
parts.setdefault("torso", {})[axis] = body.pop(axis)
|
||||||
|
moves = {}
|
||||||
|
for name, channels in parts.items():
|
||||||
|
for axis, keys in channels.items():
|
||||||
|
if axis in SCALE_AXES:
|
||||||
|
continue
|
||||||
|
for tick, value, easing in keys:
|
||||||
|
move = moves.setdefault((tick, EASINGS.get(easing, "LINEAR")), {})
|
||||||
|
move.setdefault(name, {})[axis] = round(value, 6)
|
||||||
|
return {
|
||||||
|
"version": 3,
|
||||||
|
"name": info.get("name", ""),
|
||||||
|
"description": info.get("description", ""),
|
||||||
|
"author": info.get("author", ""),
|
||||||
|
"emote": {
|
||||||
|
"beginTick": animation["begin"],
|
||||||
|
"endTick": animation["end"],
|
||||||
|
"stopTick": animation["stop"],
|
||||||
|
"isLoop": animation["loop"],
|
||||||
|
"returnTick": animation["return"],
|
||||||
|
"easeBeforeKeyframe": animation["ease_before"],
|
||||||
|
"degrees": False,
|
||||||
|
"moves": [{"tick": tick, "easing": easing, **move} for (tick, easing), move in sorted(moves.items())],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def emote_id(name):
|
||||||
|
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "emote"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("files", nargs="+")
|
||||||
|
parser.add_argument("--out-dir")
|
||||||
|
args = parser.parse_args()
|
||||||
|
failed = False
|
||||||
|
for path in args.files:
|
||||||
|
try:
|
||||||
|
info, animation, skipped = read_file(path)
|
||||||
|
except Exception as e: # report and go on to the next file
|
||||||
|
print(f"{path}: {e}", file=sys.stderr)
|
||||||
|
failed = True
|
||||||
|
continue
|
||||||
|
name = info.get("name") or Path(path).stem
|
||||||
|
# The file's name, since emote names can be in any language.
|
||||||
|
out = Path(args.out_dir or Path(path).parent) / f"{emote_id(Path(path).stem)}.json"
|
||||||
|
out.write_text(json.dumps(to_json(info, animation), indent=2))
|
||||||
|
bends = sorted(n for n, c in animation["parts"].items() if "bend" in c)
|
||||||
|
scaled = sorted(n for n, c in animation["parts"].items() if any(a in c for a in SCALE_AXES))
|
||||||
|
print(f"{Path(path).name} -> {out.name}: '{name}' by {info.get('author', '?')}, "
|
||||||
|
f"ticks {animation['begin']}-{animation['end']} (stop {animation['stop']}), "
|
||||||
|
f"{'loops' if animation['loop'] else 'once'}, parts {sorted(animation['parts'])}, "
|
||||||
|
f"bends {bends or 'none'}" + (f", scale on {scaled} (dropped)" if scaled else ""))
|
||||||
|
sys.exit(1 if failed else 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user