Compare commits

..
17 Commits
Author SHA1 Message Date
selimaj-dev 2aa740ba6c Merge pull request 'Build for Minecraft 26.1, 26.2 and 26.3' (#8) from mc-26 into master
Reviewed-on: #8
2026-09-30 16:41:02 +00:00
selimaj-devandclaude c92abd4401 Bump the version to 0.1.2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 18:40:11 +02:00
selimaj-devandclaude 9d0fe6fb6b Build for Minecraft 26.1, 26.2 and 26.3
Adds the three drops as Stonecutter versions (26.1's jar also accepts
its hotfixes). loom-back-compat already picks the plain Loom for 26.x;
the testmod's remap configurations are only created where there's
something to remap, and the foojay resolver downloads the Java 25
toolchain these versions need.

- 26.3 renamed PoseStack.mulPose(Quaternionfc) to rotate.
- The testmod follows Fabric API's renames: ClientCommands (26.1),
  getClientLevel (26.1) and getConnection (26.2) for waiting on chunks.
- 26.2 replaced Options.hideGui with the HUD's toggle, so the gametest
  toggles it through a helper.

The screenshot gametest passes on 26.1, 26.2 and 26.3, and the bent
limbs match 1.21.11's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 18:40:11 +02:00
selimaj-dev 8794f3fb65 Merge pull request 'Read binary Emotecraft emotes and fix the torso's bend direction' (#7) from emotecraft-binary into master
Reviewed-on: #7
2026-09-29 15:41:20 +00:00
selimaj-devandclaude 970e9aef51 Read binary Emotecraft emotes and fix the torso's bend direction
tools/emotecraft/convert.py turns Emotecraft's binary .emotecraft files (its network packet, with
the animation in player-animator's legacy format) into the Emotecraft JSON EmotecraftLoader reads.

Converting a few community emotes showed the loader bent the torso the wrong way: Get the Griddy,
which hunches forward, arched back. Emotecraft bends limbs the other way from us, but not the torso,
whose bend is anchored at the hips here and so already turned round. The loader now keeps the
torso's bend sign, and gives the whole player's bend to the torso, as Emotecraft does. fit.py writes
torso bends to match, so re-exported captures look the same.

Bumps the version to 0.1.1 for the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 17:37:18 +02:00
selimaj-dev 0518a2793a Merge pull request 'Add a tool that measures emotes from a store's 3D preview' (#6) from capture-poc into master
Reviewed-on: #6
2026-09-29 14:59:41 +00:00
selimaj-devandclaude df9a2587e6 Add a tool that measures emotes from a store's 3D preview
tools/capture drives a store page's skin viewer in headless Firefox, stepping its clock one tick at
a time and taking every tick from ten fixed cameras. It then fits our own rig to the frames by
rendering the model (a Python port of LimbBend and PoseApplier) and matching outlines and colours,
and writes the result as Emotecraft JSON. It measures pixels only and never reads the page's
animation data. The README covers the steps, the checks and the limits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 16:55:08 +02:00
selimaj-dev 7f8de39253 Merge pull request 'Load Emotecraft emotes into a registry and add a whole-player bone' (#5) from emote-loader into master
Reviewed-on: #5
2026-09-26 18:54:43 +00:00
selimaj-devandclaude e40aa1353b Load Emotecraft emotes into a registry and add a whole-player bone
- core: EmotecraftLoader reads Emotecraft / player-animation-lib JSON
  into a KeyframeAnimation (per-channel keyframes with Easing), following
  player-animation-lib's reader: absolute pivots become offsets, body
  (and torso before version 3) is the whole player in blocks, angles are
  degrees unless "degrees": false, turn adds whole turns, and loops jump
  back to returnTick after endTick. Emote and EmoteRegistry hold emotes
  by ID with their name, description and author. Animation.loopStart
  lets loops restart after an intro. Uses Jackson 2.17.0, the version
  Saturn Client ships.
- Bone.ROOT moves the whole player around its middle (0.7 blocks above
  the feet), through the model's root part, so armor and held items
  follow.
- fabric: Emotes.registry() and Emotes.play(uuid, id). Core and Jackson
  are bundled in the jar.
- testmod: loads Saturn's six emotes into the library's registry
  (/emotes play), and keeps its test animations in a separate registry
  (/emotes test). The gametest shoots the loaded emotes too.

Blending with the vanilla pose is issue #4.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:51:52 +02:00
selimaj-dev cf05623c54 Merge pull request 'Make held items follow bent arms and straighten the first-person arm' (#3) from held-items into master
Reviewed-on: #3
2026-09-26 18:31:22 +00:00
selimaj-devandclaude c3b0e86548 Make held items follow bent arms and straighten the first-person arm
- PlayerModelMixin: after translateToHand, move the item from the hand of
  the straight arm to the hand of the bent one (BendTransforms.attachAt,
  the same rigid move as the ring at the hand). PlayerModel overrides
  translateToHand for slim arms without calling HumanoidModel's, so the
  hook has to be on PlayerModel.
- FirstPersonHandMixin: the first-person arm is drawn from the shared
  model without setupAnim, so clear any bend left by the last
  third-person player.
- LimbBend.start()/end() getters.
- testmod: a hold animation, and gametest shots holding a sword and in
  first person.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:31:01 +02:00
selimaj-dev 7016405e5f Merge pull request 'Bend limbs and the torso along a Bézier spine' (#2) from limb-bending into master
Reviewed-on: #2
2026-09-26 18:10:27 +00:00
selimaj-devandclaude 09cfd0cc10 Arch the twerk's lower back instead of hunching it
The torso tips forward around the hips and bends backwards, so the lower
back curves in and the chest comes up. The head and arms get the tilt
themselves, since they only follow the torso's bend.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:04:08 +02:00
selimaj-devandclaude 34aba293b6 Bend the torso from the hips and add bow and twerk test animations
- LimbBend.anchoredAtEnd keeps the far end in place (the hips on the
  legs) and folds the other half, and angleAt/axis expose how far the
  limb has turned at a point.
- PoseApplier bends the body from the hips and carries the head and arms
  with it: their pivots follow the bent torso and they turn with its ring.
- Test animations: bow (torso only) and twerk. The gametest shoots the
  twerk at the top and bottom of its bounce, including from behind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:57:50 +02:00
selimaj-devandclaude 8bac5eef64 Bend limbs along a single axis with a Bézier spine
- core: Bezier (any degree, De Casteljau), LimbBend (cubic spine through
  the rigid upper/joint/lower shape, one ring per texture row spaced by
  arc length, rigid rings so pieces share vertices) and QuadSplitter
  (cuts faces at the rings, interpolating UVs).
- fabric: ModelPartMixin draws parts that have a bend with
  BentCubeRenderer, which deforms vanilla's own cubes. Overlays and armor
  use the limb's spine so they stay attached. Bends are reset every
  setupAnim because models are shared between entities.
- testmod: flap and squat animations, and a client gametest that
  screenshots each animation from the front and side
  (runClientGameTest). The tester runs with the full Fabric API because
  single modules don't bring their version-specific dependencies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:43:57 +02:00
selimaj-dev d5af8d65c2 Merge pull request 'Scaffold the emote library with a Stonecutter Fabric mod and tester' (#1) from stonecutter-scaffold into master
Reviewed-on: #1
2026-09-26 17:06:39 +00:00
selimaj-devandclaude 8641fc0afe Scaffold the emote library with a Stonecutter Fabric mod and tester
- core/: Minecraft-independent animation model (Bone, BoneTransform with
  bend fields, Pose, Animation, AnimationPlayer) with JUnit tests.
- src/main: Fabric mod built for 1.21.4-1.21.11 with Stonecutter on
  Mojang mappings. Samples each player's pose into its render state and
  applies it at the end of HumanoidModel.setupAnim (armor follows too).
- src/testmod: dev-only tester with /emotes play|stop|info and a mixin
  audit on startup.
- Publishing to the saturnclientmc Gitea Maven registry as
  org.saturnclient:emotes-core and emotes-fabric:<v>+<mc>.

Loom is pinned to 1.17 because 1.18 needs a Java 25 JVM.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 19:05:06 +02:00
85 changed files with 22175 additions and 22 deletions
+9
View File
@@ -0,0 +1,9 @@
#
# https://help.github.com/articles/dealing-with-line-endings/
#
# Linux start script should use lf
/gradlew text eol=lf
# These are Windows script files and should use crlf
*.bat text eol=crlf
+34 -20
View File
@@ -1,26 +1,40 @@
# ---> Java # gradle
# Compiled class file
*.class
# Log file .gradle/
*.log build/
out/
classes/
# BlueJ files # eclipse
*.ctxt
# Mobile Tools for Java (J2ME) *.launch
.mtj.tmp/
# Package Files # # idea
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml .idea/
hs_err_pid* *.iml
replay_pid* *.ipr
*.iws
# vscode
.settings/
.vscode/
bin/
.classpath
.project
# macos
*.DS_Store
# fabric
run/
# java
hs_err_*.log
replay_*.log
*.hprof
*.jfr
+125 -2
View File
@@ -1,3 +1,126 @@
# emote # emotes
A powerful emote library for Saturn Client, with bending A player emote library for Saturn Client, with limb bending.
## Layout
- `core/`: Minecraft-independent animation model (`Bone`, `BoneTransform`, `Pose`, `Animation`,
`AnimationPlayer`), 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` 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, 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
1. `LivingEntityRendererMixin` samples the entity's current pose into its render state (players only).
2. `HumanoidModelMixin` applies that pose at the end of `HumanoidModel.setupAnim`. `PlayerModel`
and the armor layers both go through this method, so armor follows the pose.
Both hook points exist unchanged in every supported version. Version-specific code goes behind
Stonecutter comments, like the vertex accessors in `BentCubeRenderer`:
```java
//? 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() };*/
```
## 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
```
The active version is set in `stonecutter.gradle.kts` (`stonecutter active "..."`), and it's the
version whose code is uncommented in `src/`. Switch versions with the Stonecutter IntelliJ plugin
or the `Set active project to <v>` Gradle task. Run `Reset active project` before committing, so
the tree is committed with `vcsVersion` (1.21.11) active.
Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17, which builds
26.x as well (with a Java 25 toolchain for those versions only).
## Publishing
Artifacts go to the `saturnclientmc` organization's Maven registry:
`https://git.selimaj.dev/api/packages/saturnclientmc/maven`. Use a token with `package:write`, set
as `GITEA_TOKEN` or `-PgiteaToken=...`.
+134
View File
@@ -0,0 +1,134 @@
// Build script for each Minecraft version node (versions/<mc>/). The root project itself
// is configured by stonecutter.gradle.kts.
plugins {
// Applies the right Loom variant for the node's Minecraft version.
id("dev.kikugie.loom-back-compat")
`maven-publish`
}
// DO NOT set group = ...! (Stonecutter/loom-back-compat reads mod.group.)
val modVersion = property("mod.version") as String
version = "$modVersion+${sc.current.version}"
base.archivesName = "emotes-fabric"
val requiredJava: JavaVersion = when {
sc.current.parsed >= "26.1" -> JavaVersion.VERSION_25
else -> JavaVersion.VERSION_21
}
val testmod: SourceSet = sourceSets.create("testmod") {
compileClasspath += sourceSets.main.get().compileClasspath + sourceSets.main.get().output
runtimeClasspath += sourceSets.main.get().runtimeClasspath + sourceSets.main.get().output
}
loom {
// 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()) }
register("saturn_emotes_testmod") { sourceSet(testmod) }
}
decompilerOptions.named("vineflower") {
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") {
client()
name = "Testmod Client"
source(testmod)
}
// ./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"
}
}
}
dependencies {
fun fapi(vararg modules: String) {
for (it in modules) modImplementation(fabricApi.module(it, sc.properties["deps.fabric_api"]))
}
minecraft("com.mojang:minecraft:${sc.current.version}")
loomx.applyMojangMappings()
modImplementation("net.fabricmc:fabric-loader:${property("deps.fabric_loader")}")
fapi("fabric-api-base", "fabric-lifecycle-events-v1")
// The core is Minecraft-independent; ship it inside the mod jar, with the Jackson it reads emotes
// with (the version Saturn Client ships too, so the game only loads one).
api(project(":core"))
include(project(":core"))
for (module in listOf("jackson-databind", "jackson-core", "jackson-annotations")) {
include("com.fasterxml.jackson.core:$module:2.17.0")
}
for (module in listOf("fabric-command-api-v2", "fabric-client-gametest-api-v1")) {
"modTestmodImplementation"(fabricApi.module(module, sc.properties["deps.fabric_api"]))
}
// 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 {
withSourcesJar()
targetCompatibility = requiredJava
sourceCompatibility = requiredJava
toolchain.languageVersion = JavaLanguageVersion.of(requiredJava.majorVersion)
}
tasks {
val props = mapOf(
"id" to sc.properties["mod.id"],
"name" to sc.properties["mod.name"],
"version" to version.toString(),
"minecraft" to sc.properties["mod.mc_compat"],
"loader" to Regex("\\d+\\.\\d+").find(sc.properties.get<String>("deps.fabric_loader"))!!.value,
"java" to "JAVA_${requiredJava.majorVersion}",
)
withType<ProcessResources>().configureEach {
inputs.properties(props)
filesMatching(listOf("fabric.mod.json", "*.mixins.json")) { expand(props) }
}
withType<Jar>().configureEach {
from(rootProject.file("LICENSE")) { rename { "${it}_saturn_emotes" } }
}
register<Copy>("buildAndCollect") {
group = "build"
description = "Builds the mod jar and copies it to build/libs/<mod version>/ in the root project"
from(loomx.modJar.flatMap { it.archiveFile })
into(rootProject.layout.buildDirectory.dir("libs/$modVersion"))
}
}
publishing {
publications {
create<MavenPublication>("maven") {
groupId = property("mod.group") as String
artifactId = "emotes-fabric"
from(components["java"])
}
}
}
apply(from = rootProject.file("gradle/gitea.gradle.kts"))
+41
View File
@@ -0,0 +1,41 @@
plugins {
`java-library`
`maven-publish`
}
group = "org.saturnclient"
version = rootProject.property("mod.version") as String
repositories {
mavenCentral()
}
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")
}
java {
// Kept at 21 so it runs on every supported Minecraft version.
toolchain.languageVersion = JavaLanguageVersion.of(21)
withSourcesJar()
}
tasks.test {
useJUnitPlatform()
}
publishing {
publications {
create<MavenPublication>("maven") {
artifactId = "emotes-core"
from(components["java"])
}
}
}
apply(from = rootProject.file("gradle/gitea.gradle.kts"))
@@ -0,0 +1,25 @@
package org.saturnclient.emotes.core;
/** Something that produces a {@link Pose} for any point in time. */
public interface Animation {
/**
* @param tick time since the animation started, in game ticks (20 per second). Fractional
* values come from the render partial tick.
*/
Pose sample(float tick);
/** Length in ticks. Looping animations repeat after this, others end. */
float length();
default boolean loops() {
return false;
}
/**
* 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;
}
}
@@ -0,0 +1,45 @@
package org.saturnclient.emotes.core;
/** Plays one {@link Animation} from a fixed start tick. */
public final class AnimationPlayer {
private final Animation animation;
private final long startTick;
public AnimationPlayer(Animation animation, long startTick) {
this.animation = animation;
this.startTick = startTick;
}
public Animation animation() {
return animation;
}
/** Elapsed animation time, wrapped for looping animations. */
public float time(long tick, float partialTick) {
float elapsed = Math.max(0, tick - startTick + partialTick);
float length = animation.length();
if (!animation.loops()) {
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();
}
public Pose sample(long tick, float partialTick) {
if (isFinished(tick)) {
return Pose.EMPTY;
}
return animation.sample(time(tick, partialTick));
}
}
@@ -0,0 +1,18 @@
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,
RIGHT_LEG,
LEFT_LEG
}
@@ -0,0 +1,24 @@
package org.saturnclient.emotes.core;
/**
* How one bone is posed. Offsets are in model pixels (1/16 block) relative to the bone's default
* pivot, rotations are in radians and replace the vanilla rotation.
*
* @param bend how far the lower half of the limb (forearm, shin, lower torso) folds, in radians
* @param bendAxis the direction the limb folds in, as an angle around the limb's long axis
*/
public record BoneTransform(
float x, float y, float z,
float pitch, float yaw, float roll,
float bend, float bendAxis) {
public static final BoneTransform IDENTITY = new BoneTransform(0, 0, 0, 0, 0, 0, 0, 0);
public static BoneTransform rotation(float pitch, float yaw, float roll) {
return new BoneTransform(0, 0, 0, pitch, yaw, roll, 0, 0);
}
public BoneTransform withBend(float bend, float bendAxis) {
return new BoneTransform(x, y, z, pitch, yaw, roll, bend, bendAxis);
}
}
@@ -0,0 +1,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,48 @@
package org.saturnclient.emotes.core;
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
/** An immutable snapshot of bone transforms. Bones without a transform keep their vanilla pose. */
public final class Pose {
public static final Pose EMPTY = new Pose(new EnumMap<>(Bone.class));
private final EnumMap<Bone, BoneTransform> bones;
private Pose(EnumMap<Bone, BoneTransform> bones) {
this.bones = bones;
}
public Optional<BoneTransform> get(Bone bone) {
return Optional.ofNullable(bones.get(bone));
}
public Map<Bone, BoneTransform> bones() {
return Map.copyOf(bones);
}
public boolean isEmpty() {
return bones.isEmpty();
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private final EnumMap<Bone, BoneTransform> bones = new EnumMap<>(Bone.class);
private Builder() {
}
public Builder set(Bone bone, BoneTransform transform) {
bones.put(bone, transform);
return this;
}
public Pose build() {
return bones.isEmpty() ? EMPTY : new Pose(new EnumMap<>(bones));
}
}
}
@@ -0,0 +1,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);
}
}
}
@@ -0,0 +1,90 @@
package org.saturnclient.emotes.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class AnimationPlayerTest {
/** Rotates the head by one radian per tick. */
private static Animation headSpin(float length, boolean loops) {
return new Animation() {
@Override
public Pose sample(float tick) {
return Pose.builder().set(Bone.HEAD, BoneTransform.rotation(0, tick, 0)).build();
}
@Override
public float length() {
return length;
}
@Override
public boolean loops() {
return loops;
}
};
}
private static float headYaw(Pose pose) {
return pose.get(Bone.HEAD).orElseThrow().yaw();
}
@Test
void samplesRelativeToStartTick() {
AnimationPlayer player = new AnimationPlayer(headSpin(20, false), 100);
assertEquals(5.5f, headYaw(player.sample(105, 0.5f)), 1e-6);
}
@Test
void finishesAfterLength() {
AnimationPlayer player = new AnimationPlayer(headSpin(20, false), 100);
assertFalse(player.isFinished(119));
assertTrue(player.isFinished(120));
assertTrue(player.sample(120, 0).isEmpty());
}
@Test
void loopingAnimationsWrap() {
AnimationPlayer player = new AnimationPlayer(headSpin(20, true), 0);
assertFalse(player.isFinished(1_000));
assertEquals(5f, headYaw(player.sample(45, 0)), 1e-6);
}
@Test
void timeBeforeStartClampsToZero() {
AnimationPlayer player = new AnimationPlayer(headSpin(20, false), 100);
assertEquals(0f, player.time(90, 0), 1e-6);
}
@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
+305
View File
@@ -0,0 +1,305 @@
{
"version": 1,
"valid": true,
"name": "cry",
"description": "T_T",
"author": "dsty",
"emote": {
"beginTick": 0,
"endTick": 60,
"stopTick": 61,
"isLoop": true,
"returnTick": 5,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.34134695
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.22808763
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.21269174
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.32248768
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.0874397
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.17582592
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.21476103
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"yaw": -6.320644E-4
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.029434478
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.039604172
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.037607383
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.050604735
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"roll": -0.00930274
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0010032838
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"roll": -0.009209918
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"roll": -0.012478679
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"roll": 0.031160546
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.8700758
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.4349194
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.4970688
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.9622451
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.9203311
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.6307226
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -0.2971223
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.8864816
}
}
]
}
}
@@ -0,0 +1,389 @@
{
"version": 1,
"valid": true,
"name": "facepalm",
"description": "just facepalm",
"author": "dsty",
"emote": {
"beginTick": 0,
"endTick": 60,
"stopTick": 61,
"isLoop": false,
"returnTick": 2,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.067995355
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.14453278
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.11871816
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.19018666
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.20064461
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.060454715
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.086299464
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.087619215
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.083825104
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.036273066
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0041144555
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"roll": 0.12942825
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"roll": -0.050149817
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"roll": 0.08701207
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"roll": 0.08676299
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"x": -3.8523226
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"x": -3.8523226
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.0664942
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.074402
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.074402
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.02932
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.02932
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.47555572
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.5093512
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.5093512
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.38563538
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.38563538
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.20147677
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.20521602
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.3274244
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.30787703
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.40478125
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.49199954
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.54730254
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.52019864
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
}
]
}
}
@@ -0,0 +1,697 @@
{
"version": 1,
"valid": true,
"name": "front_flip",
"description": "Parkour!!!!1",
"author": "DKinchik",
"emote": {
"beginTick": 0,
"endTick": 17,
"stopTick": 18,
"isLoop": false,
"returnTick": 1,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.7020477
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.7020477
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"x": -1.9
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"x": -1.9
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 11.169379
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 12.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 12.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"z": -0.39214998
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"z": 0.1
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"z": 0.1
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -1.9707682
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -1.9707682
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"roll": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.3665518
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.3665518
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0793308
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0793308
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"x": 1.9
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"x": 1.9
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 10.861001
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 12.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 12.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.5082285
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.1
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.1
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -1.9850811
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -1.9850811
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"x": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"x": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"y": -0.061333913
}
},
{
"tick": 7,
"easing": "OUTQUAD",
"body": {
"y": 0.48852682
}
},
{
"tick": 15,
"easing": "CONSTANT",
"body": {
"y": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"y": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"z": -0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"pitch": 0.0
}
},
{
"tick": 7,
"easing": "OUTQUAD",
"body": {
"pitch": -2.8362873
}
},
{
"tick": 15,
"easing": "CONSTANT",
"body": {
"pitch": -6.2831855
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"pitch": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"yaw": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"yaw": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"roll": -0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"body": {
"bend": -0.0
}
}
]
}
}
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
{
"version": 1,
"valid": true,
"name": "tpose",
"description": "MENACING",
"author": "stephanieisdumb",
"emote": {
"beginTick": 0,
"endTick": 80,
"stopTick": 81,
"isLoop": true,
"returnTick": 20,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"x": -6.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"x": -6.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"y": 3.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"y": 3.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.0704756
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": 0.059108086
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.61077195
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.008776078
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.6060502
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5707964
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5765934
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5707964
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -1.3806202
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"x": 6.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"x": 6.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"y": 3.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"y": 3.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0045553
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.010133168
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.44322997
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.038214765
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -0.5571526
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5707964
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5692185
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5707964
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -1.3810014
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": 0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": 0.0
}
}
]
}
}
+5
View File
@@ -0,0 +1,5 @@
# Gradle options only; mod and dependency versions live in stonecutter.properties.toml.
org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
+17
View File
@@ -0,0 +1,17 @@
// Shared publishing target: the saturnclientmc org's Maven registry on Gitea.
// Publish with a token that has package:write: GITEA_TOKEN=... ./gradlew publish
configure<PublishingExtension> {
repositories {
maven {
name = "Gitea"
url = uri("https://git.selimaj.dev/api/packages/saturnclientmc/maven")
credentials(HttpHeaderCredentials::class) {
name = "Authorization"
value = "token ${findProperty("giteaToken") ?: System.getenv("GITEA_TOKEN") ?: ""}"
}
authentication {
create<HttpHeaderAuthentication>("header")
}
}
}
}
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+112
View File
@@ -0,0 +1,112 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+30
View File
@@ -0,0 +1,30 @@
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
maven("https://maven.fabricmc.net/")
maven("https://maven.kikugie.dev/releases") { name = "KikuGie Releases" }
maven("https://maven.kikugie.dev/snapshots") { name = "KikuGie Snapshots" }
}
}
plugins {
// 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"
}
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", "26.1", "26.2", "26.3")
vcsVersion = "1.21.11"
}
}
rootProject.name = "emotes"
// Minecraft-independent animation code, shared by every version node.
include("core")
@@ -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() };*/
}
}
@@ -0,0 +1,75 @@
package org.saturnclient.emotes.fabric;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.saturnclient.emotes.core.Animation;
import org.saturnclient.emotes.core.AnimationPlayer;
import org.saturnclient.emotes.core.Emote;
import org.saturnclient.emotes.core.EmoteRegistry;
import org.saturnclient.emotes.core.Pose;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
/** Client-side entry point: which player is playing which animation. */
public final class Emotes implements ClientModInitializer {
public static final String MOD_ID = "saturn_emotes";
public static final String VERSION = /*$ mod_version*/ "0.1.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;
@Override
public void onInitializeClient() {
ClientTickEvents.END_CLIENT_TICK.register(client -> {
tick++;
PLAYING.values().removeIf(player -> player.isFinished(tick));
});
}
/** 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);
}
public static void stopAll() {
PLAYING.clear();
}
public static Optional<AnimationPlayer> playing(UUID player) {
return Optional.ofNullable(PLAYING.get(player));
}
/** The pose to render for this player this frame, or {@link Pose#EMPTY}. */
public static Pose sample(UUID player, float partialTick) {
AnimationPlayer animation = PLAYING.get(player);
return animation == null ? Pose.EMPTY : animation.sample(tick, partialTick);
}
}
@@ -0,0 +1,146 @@
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;
/**
* Writes a {@link Pose} onto a humanoid model after vanilla has posed it. Overlay parts (hat, sleeves,
* jacket, pants) are children of the base parts, so they follow automatically.
*/
public final class PoseApplier {
/** 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) {
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;
case LEFT_ARM -> model.leftArm;
case RIGHT_LEG -> model.rightLeg;
case LEFT_LEG -> model.leftLeg;
};
}
private static void apply(ModelPart part, BoneTransform transform) {
// The model's pose was reset to its defaults before setupAnim, so offsets are relative to those.
part.x += transform.x();
part.y += transform.y();
part.z += transform.z();
part.xRot = transform.pitch();
part.yRot = transform.yaw();
part.zRot = transform.roll();
}
/**
* 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,10 @@
package org.saturnclient.emotes.fabric.access;
import org.saturnclient.emotes.core.Pose;
/** Mixed into render states so the model knows the pose of the entity it's drawing. */
public interface EmotePoseHolder {
Pose saturnEmotes$getPose();
void saturnEmotes$setPose(Pose pose);
}
@@ -0,0 +1,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);
}
}
@@ -0,0 +1,26 @@
package org.saturnclient.emotes.fabric.mixin;
import org.saturnclient.emotes.fabric.PoseApplier;
import org.saturnclient.emotes.fabric.access.EmotePoseHolder;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.renderer.entity.state.HumanoidRenderState;
/**
* PlayerModel.setupAnim ends by calling this, and armor layers call it with the same state,
* so the player and their armor both get the pose.
*/
@Mixin(HumanoidModel.class)
public class HumanoidModelMixin {
@Inject(
method = "setupAnim(Lnet/minecraft/client/renderer/entity/state/HumanoidRenderState;)V",
at = @At("TAIL"))
private void saturnEmotes$applyPose(HumanoidRenderState state, CallbackInfo ci) {
// 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,24 @@
package org.saturnclient.emotes.fabric.mixin;
import org.saturnclient.emotes.core.Pose;
import org.saturnclient.emotes.fabric.access.EmotePoseHolder;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import net.minecraft.client.renderer.entity.state.LivingEntityRenderState;
@Mixin(LivingEntityRenderState.class)
public class LivingEntityRenderStateMixin implements EmotePoseHolder {
@Unique
private Pose saturnEmotes$pose = Pose.EMPTY;
@Override
public Pose saturnEmotes$getPose() {
return saturnEmotes$pose;
}
@Override
public void saturnEmotes$setPose(Pose pose) {
saturnEmotes$pose = pose;
}
}
@@ -0,0 +1,27 @@
package org.saturnclient.emotes.fabric.mixin;
import org.saturnclient.emotes.core.Pose;
import org.saturnclient.emotes.fabric.Emotes;
import org.saturnclient.emotes.fabric.access.EmotePoseHolder;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import net.minecraft.client.renderer.entity.LivingEntityRenderer;
import net.minecraft.client.renderer.entity.state.LivingEntityRenderState;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
@Mixin(LivingEntityRenderer.class)
public class LivingEntityRendererMixin {
@Inject(
method = "extractRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;F)V",
at = @At("TAIL"))
private void saturnEmotes$extractPose(LivingEntity entity, LivingEntityRenderState state, float partialTick,
CallbackInfo ci) {
// Render states are reused between entities, so always overwrite.
Pose pose = entity instanceof Player ? Emotes.sample(entity.getUUID(), partialTick) : Pose.EMPTY;
((EmotePoseHolder) state).saturnEmotes$setPose(pose);
}
}
@@ -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());
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"id": "${id}",
"version": "${version}",
"name": "${name}",
"description": "Player emote library for Saturn Client, with limb bending.",
"authors": ["Saturn Client"],
"contact": {
"sources": "https://git.selimaj.dev/saturnclientmc/emotes"
},
"license": "Apache-2.0",
"environment": "client",
"entrypoints": {
"client": ["org.saturnclient.emotes.fabric.Emotes"]
},
"mixins": ["saturn_emotes.mixins.json"],
"depends": {
"fabricloader": ">=${loader}",
"minecraft": "${minecraft}",
"fabric-lifecycle-events-v1": "*"
}
}
@@ -0,0 +1,16 @@
{
"required": true,
"package": "org.saturnclient.emotes.fabric.mixin",
"compatibilityLevel": "${java}",
"client": [
"FirstPersonHandMixin",
"HumanoidModelMixin",
"LivingEntityRenderStateMixin",
"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);
}
}
@@ -0,0 +1,96 @@
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.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
import org.saturnclient.emotes.core.Emote;
import org.saturnclient.emotes.core.EmoteRegistry;
import org.saturnclient.emotes.fabric.Emotes;
import org.spongepowered.asm.mixin.MixinEnvironment;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
import net.minecraft.network.chat.Component;
/**
* Dev-only tester. In game: {@code /emotes play <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("id", StringArgumentType.word())
.suggests((ctx, builder) -> {
Emotes.registry().ids().forEach(builder::suggest);
return builder.buildFuture();
})
.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 + ". Emotes: "
+ String.join(", ", Emotes.registry().ids()) + ". Test animations: "
+ String.join(", ", TestAnimations.REGISTRY.ids())));
return 1;
}))));
}
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(), emote.get().animation());
return 1;
}
}
@@ -0,0 +1,162 @@
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. 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);
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,
0.4f * (float) Math.sin(t * 0.4f)))
.build()),
// Arms straight out, then back after 3 seconds. Tests non-looping end.
"tpose", of(60, false, t -> Pose.builder()
.set(Bone.RIGHT_ARM, BoneTransform.rotation(0, 0, HALF_PI))
.set(Bone.LEFT_ARM, BoneTransform.rotation(0, 0, -HALF_PI))
.build()),
// Body bobbing down and up. Tests offsets.
"bob", of(20, true, t -> {
float drop = 2f * (1 - (float) Math.cos(t / 20f * 2 * Math.PI));
return Pose.builder()
.set(Bone.HEAD, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
.set(Bone.BODY, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
.set(Bone.RIGHT_ARM, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
.set(Bone.LEFT_ARM, new BoneTransform(0, drop, 0, 0, 0, 0, 0, 0))
.build();
}),
// Arms forward with elbows folding in and out. 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
public Pose sample(float tick) {
return sampler.apply(tick);
}
@Override
public float length() {
return length;
}
@Override
public boolean loops() {
return loops;
}
};
}
}
@@ -0,0 +1,305 @@
{
"version": 1,
"valid": true,
"name": "cry",
"description": "T_T",
"author": "dsty",
"emote": {
"beginTick": 0,
"endTick": 60,
"stopTick": 61,
"isLoop": true,
"returnTick": 5,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.34134695
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.22808763
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.21269174
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.32248768
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.0874397
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.17582592
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.21476103
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"yaw": -6.320644E-4
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.029434478
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.039604172
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.037607383
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.050604735
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"roll": -0.00930274
}
},
{
"tick": 25,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0010032838
}
},
{
"tick": 35,
"easing": "INOUTQUAD",
"head": {
"roll": -0.009209918
}
},
{
"tick": 45,
"easing": "INOUTQUAD",
"head": {
"roll": -0.012478679
}
},
{
"tick": 55,
"easing": "INOUTQUAD",
"head": {
"roll": 0.031160546
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.8700758
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.4349194
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.4970688
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.9622451
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.9203311
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.6307226
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -0.2971223
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.8864816
}
}
]
}
}
@@ -0,0 +1,389 @@
{
"version": 1,
"valid": true,
"name": "facepalm",
"description": "just facepalm",
"author": "dsty",
"emote": {
"beginTick": 0,
"endTick": 60,
"stopTick": 61,
"isLoop": false,
"returnTick": 2,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.067995355
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.14453278
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.11871816
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.19018666
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.20064461
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.060454715
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.086299464
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.087619215
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.083825104
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"yaw": 0.036273066
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0041144555
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"head": {
"roll": 0.12942825
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"head": {
"roll": -0.050149817
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"head": {
"roll": 0.08701207
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"head": {
"roll": 0.08676299
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"x": -3.8523226
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"x": -3.8523226
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.0664942
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.074402
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.074402
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.02932
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -2.02932
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.47555572
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.5093512
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.5093512
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.38563538
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.38563538
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.20147677
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.20521602
}
},
{
"tick": 30,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.3274244
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.30787703
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.40478125
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.49199954
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.54730254
}
},
{
"tick": 40,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.52019864
}
},
{
"tick": 60,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
}
]
}
}
@@ -0,0 +1,697 @@
{
"version": 1,
"valid": true,
"name": "front_flip",
"description": "Parkour!!!!1",
"author": "DKinchik",
"emote": {
"beginTick": 0,
"endTick": 17,
"stopTick": 18,
"isLoop": false,
"returnTick": 1,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"x": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"y": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.7020477
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"head": {
"pitch": 0.7020477
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"head": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"x": -1.9
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"x": -1.9
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 11.169379
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 12.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"y": 12.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"z": -0.39214998
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"z": 0.1
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"z": 0.1
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -1.9707682
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -1.9707682
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightLeg": {
"roll": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightLeg": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.3665518
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.3665518
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"rightArm": {
"roll": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0793308
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0793308
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"roll": 0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftArm": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"x": 1.9
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"x": 1.9
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 10.861001
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 12.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"y": 12.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.5082285
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.1
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"z": -0.1
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 3,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 5,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -1.9850811
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -1.9850811
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"yaw": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"yaw": -0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"roll": -0.0
}
},
{
"tick": 15,
"easing": "INOUTQUAD",
"leftLeg": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"leftLeg": {
"bend": -0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"x": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"x": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"y": -0.061333913
}
},
{
"tick": 7,
"easing": "OUTQUAD",
"body": {
"y": 0.48852682
}
},
{
"tick": 15,
"easing": "CONSTANT",
"body": {
"y": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"y": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"z": -0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"z": -0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"pitch": 0.0
}
},
{
"tick": 7,
"easing": "OUTQUAD",
"body": {
"pitch": -2.8362873
}
},
{
"tick": 15,
"easing": "CONSTANT",
"body": {
"pitch": -6.2831855
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"pitch": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"yaw": 0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"yaw": 0.0
}
},
{
"tick": 2,
"easing": "INQUAD",
"body": {
"roll": -0.0
}
},
{
"tick": 16,
"easing": "INQUAD",
"body": {
"roll": 0.0
}
},
{
"tick": 2,
"easing": "INOUTQUAD",
"body": {
"bend": -0.0
}
}
]
}
}
@@ -0,0 +1,354 @@
{
"version": 1,
"valid": true,
"name": "tpose",
"description": "MENACING",
"author": "stephanieisdumb",
"emote": {
"beginTick": 0,
"endTick": 80,
"stopTick": 81,
"isLoop": true,
"returnTick": 20,
"nsfw": false,
"degrees": false,
"moves": [
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"x": -5.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"x": -6.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"x": -6.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"y": 2.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"y": 3.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"y": 3.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -1.0704756
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": 0.059108086
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"pitch": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.61077195
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.008776078
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 0.6060502
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5707964
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5765934
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightArm": {
"roll": 1.5707964
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -1.3806202
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"rightArm": {
"bend": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"x": 5.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"x": 6.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"x": 6.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"y": 2.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"y": 3.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"y": 3.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"z": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": -1.0045553
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.010133168
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"pitch": 0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.44322997
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": 0.038214765
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"yaw": -0.0
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -0.5571526
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5707964
}
},
{
"tick": 50,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5692185
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftArm": {
"roll": -1.5707964
}
},
{
"tick": 10,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -1.3810014
}
},
{
"tick": 20,
"easing": "INOUTQUAD",
"leftArm": {
"bend": -0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"leftLeg": {
"pitch": 0.0
}
},
{
"tick": 80,
"easing": "INOUTQUAD",
"rightLeg": {
"pitch": 0.0
}
}
]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"id": "saturn_emotes_testmod",
"version": "${version}",
"name": "Saturn Emotes Tester",
"description": "Dev-only commands for trying Saturn Emotes in game.",
"license": "Apache-2.0",
"environment": "client",
"entrypoints": {
"client": ["org.saturnclient.emotes.testmod.EmotesTestmod"],
"fabric-client-gametest": ["org.saturnclient.emotes.testmod.EmotesGameTest"]
},
"depends": {
"saturn_emotes": "*",
"fabric-command-api-v2": "*"
}
}
+23
View File
@@ -0,0 +1,23 @@
plugins {
id("dev.kikugie.stonecutter")
}
stonecutter active "1.21.11"
// See https://stonecutter.kikugie.dev/wiki/config/params
stonecutter parameters {
swaps["mod_version"] = "\"${property("mod.version")}\";"
swaps["minecraft"] = "\"${node.metadata.version}\";"
replacements {
string(current.parsed >= "1.21.11") {
replace("ResourceLocation", "Identifier")
}
}
}
// Builds and collects every version's jar into build/libs/<mod version>/.
tasks.register("buildAll") {
group = "build"
dependsOn(stonecutter.tasks.named("buildAndCollect"))
}
+53
View File
@@ -0,0 +1,53 @@
# See https://stonecutter.kikugie.dev/wiki/config/properties#centralized-properties
mod.id = "saturn_emotes"
mod.name = "Saturn Emotes"
mod.group = "org.saturnclient"
mod.version = "0.1.2"
deps.fabric_loader = "0.19.5"
loomx.loom_version = "1.17.21"
["1.21.4"]
mod.mc_compat = "1.21.4"
deps.fabric_api = "0.119.4+1.21.4"
["1.21.5"]
mod.mc_compat = "1.21.5"
deps.fabric_api = "0.128.2+1.21.5"
["1.21.6"]
mod.mc_compat = "1.21.6"
deps.fabric_api = "0.128.2+1.21.6"
["1.21.7"]
mod.mc_compat = "1.21.7"
deps.fabric_api = "0.129.0+1.21.7"
["1.21.8"]
mod.mc_compat = "1.21.8"
deps.fabric_api = "0.136.1+1.21.8"
["1.21.9"]
mod.mc_compat = "1.21.9"
deps.fabric_api = "0.134.1+1.21.9"
["1.21.10"]
mod.mc_compat = "1.21.10"
deps.fabric_api = "0.138.4+1.21.10"
["1.21.11"]
mod.mc_compat = "1.21.11"
deps.fabric_api = "0.141.6+1.21.11"
# 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"
+7
View File
@@ -0,0 +1,7 @@
.venv/
__pycache__/
# Captures, fetched skins and scratch output: large, and regenerated by the scripts.
frames/
skins/
*.log
*.png
+19
View File
@@ -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]));
}
}
}
}
+63
View File
@@ -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.
+140
View File
@@ -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()
+27
View File
@@ -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)
+40
View File
@@ -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")
+167
View File
@@ -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()
+55
View File
@@ -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
+341
View File
@@ -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()
+351
View File
@@ -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))
+63
View File
@@ -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()
+174
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
cma==4.5.0
numpy==2.5.3
pillow==12.3.0
playwright==1.63.0
scipy==1.18.1
+20
View File
@@ -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)
+29
View File
@@ -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")
+110
View File
@@ -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()
+85
View File
@@ -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()
+88
View File
@@ -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;
})();
+211
View File
@@ -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()