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
|
||||
|
||||
- `core/`: Minecraft-independent animation model (`Bone`, `BoneTransform`, `Pose`, `Animation`,
|
||||
`AnimationPlayer`). Plain Java 21 with JUnit tests. Published as `org.saturnclient:emotes-core`.
|
||||
`AnimationPlayer`), keyframes and easings, the Emotecraft JSON loader and the `EmoteRegistry`.
|
||||
Plain Java 21 plus Jackson, with JUnit tests. Published as `org.saturnclient:emotes-core`.
|
||||
- `src/main/`: the Fabric mod that puts poses on player models. It's built once per Minecraft
|
||||
version with [Stonecutter](https://stonecutter.kikugie.dev/) and published as
|
||||
`org.saturnclient:emotes-fabric:<version>+<mc>`, with `core` bundled inside the jar.
|
||||
- `src/testmod/`: a dev-only tester mod that is never published. It adds `/emotes play <name>`,
|
||||
`/emotes stop` and `/emotes info`. Press F5 to see yourself.
|
||||
`org.saturnclient:emotes-fabric:<version>+<mc>`, with `core` and Jackson 2.17.0 (the version Saturn
|
||||
Client ships) bundled inside the jar.
|
||||
- `src/testmod/`: a dev-only tester mod that is never published. It loads Saturn's emotes into the
|
||||
library's registry and keeps its own hand-written test animations in a separate registry.
|
||||
`/emotes play <id>` plays a registered emote, `/emotes test <name>` a test animation, and there's
|
||||
`/emotes stop` and `/emotes info`. Press F5 to see yourself. It also has a Fabric client gametest
|
||||
that creates a world and screenshots each animation from the front and side, into
|
||||
`versions/<mc>/build/run/clientGameTest/screenshots/`.
|
||||
- `tools/capture/`: a Python tool that measures an emote from a store page's 3D preview and writes
|
||||
it as Emotecraft JSON. See its README.
|
||||
- `tools/emotecraft/convert.py`: converts Emotecraft's binary `.emotecraft` files to Emotecraft JSON
|
||||
(standard-library Python).
|
||||
|
||||
Supported versions: 1.21.4 to 1.21.11, the same range as Saturn Client. Each version's Fabric API
|
||||
version is set in `stonecutter.properties.toml`. The code uses Mojang mappings.
|
||||
Supported versions: 1.21.4 to 1.21.11, 26.1 (and its hotfixes), 26.2 and 26.3. Each version's
|
||||
Fabric API version is set in `stonecutter.properties.toml`. The code uses Mojang mappings, which
|
||||
26.1+ ships as its own names, so `loom-back-compat` applies the remapping Loom for 1.21.x and the
|
||||
plain one for 26.x. 26.x builds need Java 25; Gradle downloads it when it isn't installed.
|
||||
|
||||
## Using it
|
||||
|
||||
```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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
//? 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
|
||||
/*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
|
||||
|
||||
```sh
|
||||
./gradlew :core:test # core unit tests
|
||||
./gradlew :1.21.11:runTestmodClient # launch the game with the tester (run dir: ./run)
|
||||
./gradlew :1.21.11:runClientGameTest # screenshot each test animation, then quit
|
||||
./gradlew :1.21.4:compileTestmodJava # quick compile check for one version
|
||||
./gradlew buildAll # every version's jar → build/libs/<version>/
|
||||
GITEA_TOKEN=... ./gradlew publish # publish core + every version to Gitea
|
||||
@@ -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
|
||||
the tree is committed with `vcsVersion` (1.21.11) active.
|
||||
|
||||
Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17 until the
|
||||
toolchain moves. Minecraft 26.x will need that upgrade.
|
||||
Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17, which builds
|
||||
26.x as well (with a Java 25 toolchain for those versions only).
|
||||
|
||||
## Publishing
|
||||
|
||||
|
||||
+28
-9
@@ -22,7 +22,8 @@ val testmod: SourceSet = sourceSets.create("testmod") {
|
||||
}
|
||||
|
||||
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 {
|
||||
register("saturn_emotes") { sourceSet(sourceSets.main.get()) }
|
||||
@@ -33,6 +34,13 @@ loom {
|
||||
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 {
|
||||
// ./gradlew :<mc>:runTestmodClient launches the game with the tester mod loaded.
|
||||
register("testmodClient") {
|
||||
@@ -40,13 +48,15 @@ loom {
|
||||
name = "Testmod Client"
|
||||
source(testmod)
|
||||
}
|
||||
}
|
||||
|
||||
runConfigs.all {
|
||||
preferGradleTask = true
|
||||
generateRunConfig = true
|
||||
runDirectory = rootProject.file("run") // Shared by every version
|
||||
jvmArguments.add("-Dmixin.debug.export=true")
|
||||
// ./gradlew :<mc>:runClientGameTest screenshots the test animations, then quits.
|
||||
register("clientGameTest") {
|
||||
client()
|
||||
name = "Client Game Test"
|
||||
source(testmod)
|
||||
vmArg("-Dfabric.client.gametest")
|
||||
runDir = "build/run/clientGameTest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +71,20 @@ dependencies {
|
||||
modImplementation("net.fabricmc:fabric-loader:${property("deps.fabric_loader")}")
|
||||
fapi("fabric-api-base", "fabric-lifecycle-events-v1")
|
||||
|
||||
// The core is Minecraft-independent; ship it inside the mod jar.
|
||||
// The core is Minecraft-independent; ship it inside the mod jar, with the Jackson it reads emotes
|
||||
// with (the version Saturn Client ships too, so the game only loads one).
|
||||
api(project(":core"))
|
||||
include(project(":core"))
|
||||
for (module in listOf("jackson-databind", "jackson-core", "jackson-annotations")) {
|
||||
include("com.fasterxml.jackson.core:$module:2.17.0")
|
||||
}
|
||||
|
||||
"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 {
|
||||
|
||||
@@ -11,6 +11,9 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Same version Saturn Client ships, so only one copy ends up in the game.
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.0")
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
|
||||
@@ -14,4 +14,12 @@ public interface Animation {
|
||||
default boolean loops() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a looping animation starts again after reaching {@link #length()}, in ticks. The part
|
||||
* before it plays once, like an intro.
|
||||
*/
|
||||
default float loopStart() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,19 @@ public final class AnimationPlayer {
|
||||
public float time(long tick, float partialTick) {
|
||||
float elapsed = Math.max(0, tick - startTick + partialTick);
|
||||
float length = animation.length();
|
||||
if (animation.loops() && length > 0) {
|
||||
return elapsed % length;
|
||||
}
|
||||
if (!animation.loops()) {
|
||||
return Math.min(elapsed, length);
|
||||
}
|
||||
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) {
|
||||
return !animation.loops() && tick - startTick >= animation.length();
|
||||
|
||||
@@ -2,7 +2,14 @@ package org.saturnclient.emotes.core;
|
||||
|
||||
/** The parts of the player model an animation can move. */
|
||||
public enum Bone {
|
||||
/**
|
||||
* The whole player. Offsets are in model pixels (Y down, like the other bones), and rotations turn
|
||||
* the whole model around its middle, 12.8 pixels below the neck (0.7 blocks above the feet). Used
|
||||
* for flips, spins and jumps.
|
||||
*/
|
||||
ROOT,
|
||||
HEAD,
|
||||
/** The torso. It bends from the hips; see {@code LimbBend#anchoredAtEnd}. */
|
||||
BODY,
|
||||
RIGHT_ARM,
|
||||
LEFT_ARM,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.saturnclient.emotes.core;
|
||||
|
||||
/**
|
||||
* An animation with the details a player sees, like in an emote wheel.
|
||||
*
|
||||
* @param id identifies the emote, for example in network messages ("facepalm")
|
||||
*/
|
||||
public record Emote(String id, String name, String description, String author, Animation animation) {
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.saturnclient.emotes.core;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.saturnclient.emotes.core.format.EmotecraftLoader;
|
||||
|
||||
/** Emotes by ID, in the order they were added. Safe to use from any thread. */
|
||||
public final class EmoteRegistry {
|
||||
private final Map<String, Emote> emotes = new LinkedHashMap<>();
|
||||
|
||||
/** Adds the emote, replacing any with the same ID. */
|
||||
public synchronized Emote register(Emote emote) {
|
||||
emotes.put(emote.id(), emote);
|
||||
return emote;
|
||||
}
|
||||
|
||||
/** Reads an emote in Emotecraft's JSON format and registers it under {@code id}. */
|
||||
public Emote loadEmotecraft(String id, InputStream json) throws IOException {
|
||||
return register(EmotecraftLoader.load(id, json));
|
||||
}
|
||||
|
||||
public synchronized Optional<Emote> get(String id) {
|
||||
return Optional.ofNullable(emotes.get(id));
|
||||
}
|
||||
|
||||
public synchronized List<Emote> all() {
|
||||
return List.copyOf(emotes.values());
|
||||
}
|
||||
|
||||
public synchronized List<String> ids() {
|
||||
return new ArrayList<>(emotes.keySet());
|
||||
}
|
||||
|
||||
public synchronized boolean remove(String id) {
|
||||
return emotes.remove(id) != null;
|
||||
}
|
||||
|
||||
public synchronized void clear() {
|
||||
emotes.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
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 {
|
||||
// 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"
|
||||
// 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"
|
||||
@@ -17,7 +19,7 @@ plugins {
|
||||
stonecutter {
|
||||
create(rootProject) {
|
||||
// Same range as Saturn Client. Per-version dependencies live in stonecutter.properties.toml.
|
||||
versions("1.21.4", "1.21.5", "1.21.6", "1.21.7", "1.21.8", "1.21.9", "1.21.10", "1.21.11")
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.AnimationPlayer;
|
||||
import org.saturnclient.emotes.core.Emote;
|
||||
import org.saturnclient.emotes.core.EmoteRegistry;
|
||||
import org.saturnclient.emotes.core.Pose;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -17,9 +19,10 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
/** Client-side entry point: which player is playing which animation. */
|
||||
public final class Emotes implements ClientModInitializer {
|
||||
public static final String MOD_ID = "saturn_emotes";
|
||||
public static final String VERSION = /*$ mod_version*/ "0.1.0";
|
||||
public static final String VERSION = /*$ mod_version*/ "0.1.2";
|
||||
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
|
||||
|
||||
private static final EmoteRegistry REGISTRY = new EmoteRegistry();
|
||||
private static final Map<UUID, AnimationPlayer> PLAYING = new ConcurrentHashMap<>();
|
||||
private static long tick;
|
||||
|
||||
@@ -31,11 +34,27 @@ public final class Emotes implements ClientModInitializer {
|
||||
});
|
||||
}
|
||||
|
||||
/** The emotes {@link #play(UUID, String)} looks up. Load or register emotes into it at startup. */
|
||||
public static EmoteRegistry registry() {
|
||||
return REGISTRY;
|
||||
}
|
||||
|
||||
/** Starts {@code animation} on the player, replacing whatever they were playing. */
|
||||
public static void play(UUID player, Animation animation) {
|
||||
PLAYING.put(player, new AnimationPlayer(animation, tick));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the registered emote {@code id} on the player.
|
||||
*
|
||||
* @return false if no emote has that ID, in which case nothing changes
|
||||
*/
|
||||
public static boolean play(UUID player, String id) {
|
||||
Optional<Emote> emote = REGISTRY.get(id);
|
||||
emote.ifPresent(e -> play(player, e.animation()));
|
||||
return emote.isPresent();
|
||||
}
|
||||
|
||||
public static void stop(UUID player) {
|
||||
PLAYING.remove(player);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package org.saturnclient.emotes.fabric;
|
||||
|
||||
import org.joml.Quaternionf;
|
||||
import org.joml.Vector3f;
|
||||
import org.saturnclient.emotes.core.Bone;
|
||||
import org.saturnclient.emotes.core.BoneTransform;
|
||||
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.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.
|
||||
*/
|
||||
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() {
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
for (var entry : pose.bones().entrySet()) {
|
||||
apply(part(model, entry.getKey()), entry.getValue());
|
||||
pose.get(Bone.ROOT).ifPresent(transform -> applyRoot(model.root(), transform));
|
||||
|
||||
LimbBend bodyBend = null;
|
||||
for (Bone bone : Bone.values()) {
|
||||
if (bone == Bone.ROOT) {
|
||||
continue;
|
||||
}
|
||||
ModelPart part = part(model, bone);
|
||||
BoneTransform transform = pose.get(bone).orElse(null);
|
||||
if (transform != null) {
|
||||
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) {
|
||||
return switch (bone) {
|
||||
case ROOT -> model.root();
|
||||
case HEAD -> model.head;
|
||||
case BODY -> model.body;
|
||||
case RIGHT_ARM -> model.rightArm;
|
||||
@@ -40,6 +75,72 @@ public final class PoseApplier {
|
||||
part.xRot = transform.pitch();
|
||||
part.yRot = transform.yaw();
|
||||
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;
|
||||
|
||||
import org.saturnclient.emotes.core.Pose;
|
||||
import org.saturnclient.emotes.fabric.PoseApplier;
|
||||
import org.saturnclient.emotes.fabric.access.EmotePoseHolder;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
@@ -21,9 +20,7 @@ public class HumanoidModelMixin {
|
||||
method = "setupAnim(Lnet/minecraft/client/renderer/entity/state/HumanoidRenderState;)V",
|
||||
at = @At("TAIL"))
|
||||
private void saturnEmotes$applyPose(HumanoidRenderState state, CallbackInfo ci) {
|
||||
Pose pose = ((EmotePoseHolder) state).saturnEmotes$getPose();
|
||||
if (!pose.isEmpty()) {
|
||||
PoseApplier.apply((HumanoidModel<?>) (Object) this, pose);
|
||||
}
|
||||
// Always applied, even for an empty pose, so bends left over from another entity get cleared.
|
||||
PoseApplier.apply((HumanoidModel<?>) (Object) this, ((EmotePoseHolder) state).saturnEmotes$getPose());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"compatibilityLevel": "${java}",
|
||||
"client": [
|
||||
"FirstPersonHandMixin",
|
||||
"HumanoidModelMixin",
|
||||
"LivingEntityRenderStateMixin",
|
||||
"LivingEntityRendererMixin"
|
||||
"LivingEntityRendererMixin",
|
||||
"ModelPartMixin",
|
||||
"PlayerModelMixin"
|
||||
],
|
||||
"injectors": {
|
||||
"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;
|
||||
|
||||
//? 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.literal;
|
||||
//?}
|
||||
|
||||
import java.util.TreeSet;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.saturnclient.emotes.core.Animation;
|
||||
import org.saturnclient.emotes.core.Emote;
|
||||
import org.saturnclient.emotes.core.EmoteRegistry;
|
||||
import org.saturnclient.emotes.fabric.Emotes;
|
||||
import org.spongepowered.asm.mixin.MixinEnvironment;
|
||||
|
||||
@@ -18,46 +27,70 @@ import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/**
|
||||
* Dev-only tester. In game: {@code /emotes play <name>}, {@code /emotes stop}, {@code /emotes info}.
|
||||
* Dev-only tester. In game: {@code /emotes play <id>} plays an emote from the library's registry
|
||||
* (Saturn's emotes, loaded here), {@code /emotes test <name>} plays one of the testmod's own
|
||||
* {@link TestAnimations}, and there's {@code /emotes stop} and {@code /emotes info}.
|
||||
* Switch to third person (F5) to see yourself.
|
||||
*/
|
||||
public final class EmotesTestmod implements ClientModInitializer {
|
||||
private static final String MINECRAFT = /*$ minecraft*/ "1.21.11";
|
||||
/** Saturn Client's emotes, in Emotecraft's format, from assets/saturn_emotes_testmod/player_animations. */
|
||||
static final List<String> EMOTECRAFT_EMOTES = List.of("back_flip", "cry", "facepalm", "front_flip",
|
||||
"giga_chad", "tpose");
|
||||
|
||||
@Override
|
||||
public void onInitializeClient() {
|
||||
// Applies every mixin now, so a broken target crashes at startup instead of in a world.
|
||||
MixinEnvironment.getCurrentEnvironment().audit();
|
||||
Emotes.LOGGER.info("Mixin audit passed on Minecraft {}", MINECRAFT);
|
||||
loadEmotes();
|
||||
|
||||
ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> dispatcher.register(
|
||||
literal("emotes")
|
||||
.then(literal("play").then(argument("name", StringArgumentType.word())
|
||||
.then(literal("play").then(argument("id", StringArgumentType.word())
|
||||
.suggests((ctx, builder) -> {
|
||||
TestAnimations.ALL.keySet().forEach(builder::suggest);
|
||||
Emotes.registry().ids().forEach(builder::suggest);
|
||||
return builder.buildFuture();
|
||||
})
|
||||
.executes(EmotesTestmod::play)))
|
||||
.executes(ctx -> play(ctx, Emotes.registry(), "id"))))
|
||||
.then(literal("test").then(argument("name", StringArgumentType.word())
|
||||
.suggests((ctx, builder) -> {
|
||||
TestAnimations.REGISTRY.ids().forEach(builder::suggest);
|
||||
return builder.buildFuture();
|
||||
})
|
||||
.executes(ctx -> play(ctx, TestAnimations.REGISTRY, "name"))))
|
||||
.then(literal("stop").executes(ctx -> {
|
||||
Emotes.stop(ctx.getSource().getPlayer().getUUID());
|
||||
return 1;
|
||||
}))
|
||||
.then(literal("info").executes(ctx -> {
|
||||
ctx.getSource().sendFeedback(Component.literal("Saturn Emotes " + Emotes.VERSION
|
||||
+ " on Minecraft " + MINECRAFT + ". Animations: "
|
||||
+ String.join(", ", new TreeSet<>(TestAnimations.ALL.keySet()))));
|
||||
+ " on Minecraft " + MINECRAFT + ". Emotes: "
|
||||
+ String.join(", ", Emotes.registry().ids()) + ". Test animations: "
|
||||
+ String.join(", ", TestAnimations.REGISTRY.ids())));
|
||||
return 1;
|
||||
}))));
|
||||
}
|
||||
|
||||
private static int play(CommandContext<FabricClientCommandSource> ctx) {
|
||||
String name = StringArgumentType.getString(ctx, "name");
|
||||
Animation animation = TestAnimations.ALL.get(name);
|
||||
if (animation == null) {
|
||||
ctx.getSource().sendError(Component.literal("Unknown animation: " + name));
|
||||
private static void loadEmotes() {
|
||||
for (String id : EMOTECRAFT_EMOTES) {
|
||||
String path = "/assets/saturn_emotes_testmod/player_animations/" + id + ".json";
|
||||
try (InputStream in = EmotesTestmod.class.getResourceAsStream(path)) {
|
||||
Emotes.registry().loadEmotecraft(id, in);
|
||||
} catch (IOException | NullPointerException e) {
|
||||
Emotes.LOGGER.error("Couldn't load emote {}", id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int play(CommandContext<FabricClientCommandSource> ctx, EmoteRegistry registry, String argument) {
|
||||
String id = StringArgumentType.getString(ctx, argument);
|
||||
Optional<Emote> emote = registry.get(id);
|
||||
if (emote.isEmpty()) {
|
||||
ctx.getSource().sendError(Component.literal("Unknown: " + id));
|
||||
return 0;
|
||||
}
|
||||
Emotes.play(ctx.getSource().getPlayer().getUUID(), animation);
|
||||
Emotes.play(ctx.getSource().getPlayer().getUUID(), emote.get().animation());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
package org.saturnclient.emotes.testmod;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.saturnclient.emotes.core.Animation;
|
||||
import org.saturnclient.emotes.core.Bone;
|
||||
import org.saturnclient.emotes.core.Emote;
|
||||
import org.saturnclient.emotes.core.EmoteRegistry;
|
||||
import org.saturnclient.emotes.core.BoneTransform;
|
||||
import org.saturnclient.emotes.core.Pose;
|
||||
|
||||
/** Hand-written animations for checking that poses reach the model on every version. */
|
||||
/**
|
||||
* Hand-written animations for checking that poses reach the model on every version. They live in the
|
||||
* testmod's own registry, separate from the library's {@code Emotes.registry()}.
|
||||
*/
|
||||
final class TestAnimations {
|
||||
private static final float HALF_PI = (float) (Math.PI / 2);
|
||||
|
||||
static final Map<String, Animation> ALL = Map.of(
|
||||
private static final Map<String, Animation> ALL = Map.of(
|
||||
// Right arm raised, waving side to side. Tests rotation.
|
||||
"wave", of(40, true, t -> Pose.builder()
|
||||
.set(Bone.RIGHT_ARM, BoneTransform.rotation((float) Math.PI, 0,
|
||||
@@ -33,18 +39,108 @@ final class TestAnimations {
|
||||
.set(Bone.LEFT_ARM, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
|
||||
.build();
|
||||
}),
|
||||
// Arms forward with elbows folding in and out. Bend isn't rendered yet.
|
||||
// Arms forward with elbows folding in and out. Tests bending towards the front.
|
||||
"bend", of(40, true, t -> {
|
||||
float bend = HALF_PI * 0.5f * (1 - (float) Math.cos(t / 40f * 2 * Math.PI));
|
||||
return Pose.builder()
|
||||
.set(Bone.RIGHT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0))
|
||||
.set(Bone.LEFT_ARM, BoneTransform.rotation(-HALF_PI, 0, 0).withBend(bend, 0))
|
||||
.build();
|
||||
}));
|
||||
}),
|
||||
// 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() {
|
||||
}
|
||||
|
||||
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) {
|
||||
return new Animation() {
|
||||
@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",
|
||||
"environment": "client",
|
||||
"entrypoints": {
|
||||
"client": ["org.saturnclient.emotes.testmod.EmotesTestmod"]
|
||||
"client": ["org.saturnclient.emotes.testmod.EmotesTestmod"],
|
||||
"fabric-client-gametest": ["org.saturnclient.emotes.testmod.EmotesGameTest"]
|
||||
},
|
||||
"depends": {
|
||||
"saturn_emotes": "*",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
mod.id = "saturn_emotes"
|
||||
mod.name = "Saturn Emotes"
|
||||
mod.group = "org.saturnclient"
|
||||
mod.version = "0.1.0"
|
||||
mod.version = "0.1.2"
|
||||
|
||||
deps.fabric_loader = "0.19.5"
|
||||
loomx.loom_version = "1.17.21"
|
||||
@@ -38,3 +38,16 @@ deps.fabric_api = "0.138.4+1.21.10"
|
||||
["1.21.11"]
|
||||
mod.mc_compat = "1.21.11"
|
||||
deps.fabric_api = "0.141.6+1.21.11"
|
||||
|
||||
# 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