Compare commits
5
Commits
0518a2793a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa740ba6c | ||
|
|
c92abd4401 | ||
|
|
9d0fe6fb6b | ||
|
|
8794f3fb65 | ||
|
|
970e9aef51 |
@@ -19,9 +19,13 @@ A player emote library for Saturn Client, with limb bending.
|
||||
`versions/<mc>/build/run/clientGameTest/screenshots/`.
|
||||
- `tools/capture/`: a Python tool that measures an emote from a store page's 3D preview and writes
|
||||
it as Emotecraft JSON. See its README.
|
||||
- `tools/emotecraft/convert.py`: converts Emotecraft's binary `.emotecraft` files to Emotecraft JSON
|
||||
(standard-library Python).
|
||||
|
||||
Supported versions: 1.21.4 to 1.21.11, the same range as Saturn Client. Each version's Fabric API
|
||||
version is set in `stonecutter.properties.toml`. The code uses Mojang mappings.
|
||||
Supported versions: 1.21.4 to 1.21.11, 26.1 (and its hotfixes), 26.2 and 26.3. Each version's
|
||||
Fabric API version is set in `stonecutter.properties.toml`. The code uses Mojang mappings, which
|
||||
26.1+ ships as its own names, so `loom-back-compat` applies the remapping Loom for 1.21.x and the
|
||||
plain one for 26.x. 26.x builds need Java 25; Gradle downloads it when it isn't installed.
|
||||
|
||||
## Using it
|
||||
|
||||
@@ -47,8 +51,11 @@ pitch/yaw/roll in radians, bend and bend axis) for any of `ROOT` (the whole play
|
||||
|
||||
`EmotecraftLoader` follows player-animation-lib's reader: absolute part pivots become offsets,
|
||||
`body` (and `torso` before format version 3) is the whole player in blocks, angles are degrees unless
|
||||
`"degrees": false`, `turn` adds whole turns, and loops jump back to `returnTick` after `endTick`. Scale
|
||||
channels, `easingArg` and blending with the vanilla pose aren't supported yet (issue #4).
|
||||
`"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
|
||||
|
||||
@@ -109,8 +116,8 @@ version whose code is uncommented in `src/`. Switch versions with the Stonecutte
|
||||
or the `Set active project to <v>` Gradle task. Run `Reset active project` before committing, so
|
||||
the tree is committed with `vcsVersion` (1.21.11) active.
|
||||
|
||||
Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17 until the
|
||||
toolchain moves. Minecraft 26.x will need that upgrade.
|
||||
Loom 1.18 needs a Java 25 JVM to run Gradle, so the build is pinned to Loom 1.17, which builds
|
||||
26.x as well (with a Java 25 toolchain for those versions only).
|
||||
|
||||
## Publishing
|
||||
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ val testmod: SourceSet = sourceSets.create("testmod") {
|
||||
}
|
||||
|
||||
loom {
|
||||
createRemapConfigurations(testmod)
|
||||
// The testmod's mod* configurations; 26.1+ has nothing to remap, and loomx aliases them there.
|
||||
if (!loomx.isUnobfuscated) createRemapConfigurations(testmod)
|
||||
|
||||
mods {
|
||||
register("saturn_emotes") { sourceSet(sourceSets.main.get()) }
|
||||
|
||||
@@ -31,7 +31,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
* subtracted to get an offset.</li>
|
||||
* <li>Whole-player positions are in blocks with Y up, and its rotations are around the world axes
|
||||
* before Minecraft flips the model, so X, Y, pitch and yaw change sign.</li>
|
||||
* <li>Emotecraft bends the other way, so {@code bend} changes sign.</li>
|
||||
* <li>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
|
||||
@@ -112,8 +116,11 @@ public final class EmotecraftLoader {
|
||||
for (Map.Entry<String, Channel> axis : AXES.entrySet()) {
|
||||
JsonNode value = axes.get(axis.getKey());
|
||||
if (value != null && value.isNumber()) {
|
||||
float converted = convert(bone, axis.getValue(), value.floatValue(), degrees, turn);
|
||||
animation.keyframe(bone, axis.getValue(), tick, converted, easing);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,7 +153,7 @@ public final class EmotecraftLoader {
|
||||
float radians = angle(value, degrees, turn);
|
||||
yield bone == Bone.ROOT && channel != Channel.ROLL ? -radians : radians;
|
||||
}
|
||||
case BEND -> -angle(value, degrees, turn);
|
||||
case BEND -> bone == Bone.BODY ? angle(value, degrees, turn) : -angle(value, degrees, turn);
|
||||
case BEND_AXIS -> angle(value, degrees, turn);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +78,22 @@ class EmotecraftLoaderTest {
|
||||
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.
|
||||
|
||||
+3
-1
@@ -9,6 +9,8 @@ pluginManagement {
|
||||
}
|
||||
|
||||
plugins {
|
||||
// Downloads the Java toolchain a version needs (Java 25 for 26.1+) when it isn't installed.
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
id("dev.kikugie.stonecutter") version "0.9.8"
|
||||
// Picks the right Loom variant per Minecraft version (remapping for 1.21.x, plain for 26.1+).
|
||||
id("dev.kikugie.loom-back-compat") version "0.4.2"
|
||||
@@ -17,7 +19,7 @@ plugins {
|
||||
stonecutter {
|
||||
create(rootProject) {
|
||||
// Same range as Saturn Client. Per-version dependencies live in stonecutter.properties.toml.
|
||||
versions("1.21.4", "1.21.5", "1.21.6", "1.21.7", "1.21.8", "1.21.9", "1.21.10", "1.21.11")
|
||||
versions("1.21.4", "1.21.5", "1.21.6", "1.21.7", "1.21.8", "1.21.9", "1.21.10", "1.21.11", "26.1", "26.2", "26.3")
|
||||
vcsVersion = "1.21.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ public final class BendTransforms {
|
||||
bend.bendPoint(0, y, 0, center);
|
||||
Vec3 axis = bend.axis();
|
||||
poseStack.translate(center[0] / 16, center[1] / 16, center[2] / 16);
|
||||
poseStack.mulPose(new Quaternionf().rotationAxis(bend.angleAt(y), axis.x(), axis.y(), axis.z()));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
||||
/** Client-side entry point: which player is playing which animation. */
|
||||
public final class Emotes implements ClientModInitializer {
|
||||
public static final String MOD_ID = "saturn_emotes";
|
||||
public static final String VERSION = /*$ mod_version*/ "0.1.0";
|
||||
public static final String VERSION = /*$ mod_version*/ "0.1.2";
|
||||
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
|
||||
|
||||
private static final EmoteRegistry REGISTRY = new EmoteRegistry();
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.
|
||||
@@ -50,9 +51,14 @@ public final class EmotesGameTest implements FabricClientGameTest {
|
||||
@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 -> {
|
||||
client.options.hideGui = true;
|
||||
setHudHidden(client, true);
|
||||
client.options.setCameraType(CameraType.THIRD_PERSON_FRONT);
|
||||
});
|
||||
context.waitTicks(20);
|
||||
@@ -80,7 +86,7 @@ public final class EmotesGameTest implements FabricClientGameTest {
|
||||
singleplayer.getServer().runCommand("clear @a");
|
||||
context.waitTicks(2);
|
||||
context.runOnClient(client -> {
|
||||
client.options.hideGui = false;
|
||||
setHudHidden(client, false);
|
||||
client.options.setCameraType(CameraType.FIRST_PERSON);
|
||||
});
|
||||
// Give the hand time to come back up after the item change.
|
||||
@@ -88,11 +94,21 @@ public final class EmotesGameTest implements FabricClientGameTest {
|
||||
context.takeScreenshot(MINECRAFT + "-hold-firstperson");
|
||||
context.runOnClient(client -> {
|
||||
Emotes.stop(client.player.getUUID());
|
||||
client.options.hideGui = true;
|
||||
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 -> {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
mod.id = "saturn_emotes"
|
||||
mod.name = "Saturn Emotes"
|
||||
mod.group = "org.saturnclient"
|
||||
mod.version = "0.1.0"
|
||||
mod.version = "0.1.2"
|
||||
|
||||
deps.fabric_loader = "0.19.5"
|
||||
loomx.loom_version = "1.17.21"
|
||||
@@ -38,3 +38,16 @@ deps.fabric_api = "0.138.4+1.21.10"
|
||||
["1.21.11"]
|
||||
mod.mc_compat = "1.21.11"
|
||||
deps.fabric_api = "0.141.6+1.21.11"
|
||||
|
||||
# Built against 26.1 and allowed on its hotfixes (26.1.1, 26.1.2).
|
||||
["26.1"]
|
||||
mod.mc_compat = "~26.1"
|
||||
deps.fabric_api = "0.145.1+26.1"
|
||||
|
||||
["26.2"]
|
||||
mod.mc_compat = "26.2"
|
||||
deps.fabric_api = "0.161.0+26.2"
|
||||
|
||||
["26.3"]
|
||||
mod.mc_compat = "26.3"
|
||||
deps.fabric_api = "0.161.0+26.3"
|
||||
|
||||
@@ -28,7 +28,8 @@ def to_file(bone, channel, value):
|
||||
return value + REST.get(bone, (0, 0, 0))["xyz".index(channel)]
|
||||
degrees = np.degrees(value)
|
||||
if channel == "bend":
|
||||
return -degrees
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Converts Emotecraft's binary .emotecraft files to the Emotecraft JSON our EmotecraftLoader reads.
|
||||
|
||||
python3 convert.py <file.emotecraft>... [--out-dir DIR]
|
||||
|
||||
Writes <id>.json next to each file (or into DIR), with the id made from the file's name, and prints
|
||||
what each file holds. Standard library only.
|
||||
|
||||
The binary is Emotecraft's network packet (EmotePacket): an int version, a purpose byte and a count of
|
||||
sub-packets, each an id byte, a version byte, an int size and its data. Sub-packet 0 is the animation
|
||||
in player-animator's legacy format (PlayerAnimationLibrary's LegacyAnimationBinary), whose values are
|
||||
the same raw values Emotecraft JSON holds (radians, absolute pivots, the whole player in blocks), so
|
||||
they're written out as they are with "degrees": false. Sub-packet 17 is the name, description and
|
||||
author (header version 1 and up). Anything else (the icon, the scale channels, easing arguments) is left out, since our loader
|
||||
doesn't read it.
|
||||
|
||||
One thing moves: in the legacy format the whole-player part "body" also carries the torso's bend.
|
||||
In JSON, "body" is only the whole player, so its bend goes to "torso", as PlayerAnimationLibrary does.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ANIMATION, HEADER = 0, 17
|
||||
AXES = ["x", "y", "z", "pitch", "yaw", "roll"]
|
||||
BEND_AXES = ["axis", "bend"]
|
||||
SCALE_AXES = ["scaleX", "scaleY", "scaleZ"]
|
||||
# The fixed part order before version 2 named its parts.
|
||||
V1_PARTS = ["head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"]
|
||||
# PlayerAnimationLibrary's EasingType ids. Our Easing.fromName reads the same names.
|
||||
EASINGS = {
|
||||
0: "LINEAR", 1: "CONSTANT", 6: "EASEINSINE", 7: "EASEOUTSINE", 8: "EASEINOUTSINE",
|
||||
9: "EASEINCUBIC", 10: "EASEOUTCUBIC", 11: "EASEINOUTCUBIC", 12: "EASEINQUAD", 13: "EASEOUTQUAD",
|
||||
14: "EASEINOUTQUAD", 15: "EASEINQUART", 16: "EASEOUTQUART", 17: "EASEINOUTQUART",
|
||||
18: "EASEINQUINT", 19: "EASEOUTQUINT", 20: "EASEINOUTQUINT", 21: "EASEINEXPO", 22: "EASEOUTEXPO",
|
||||
23: "EASEINOUTEXPO", 24: "EASEINCIRC", 25: "EASEOUTCIRC", 26: "EASEINOUTCIRC", 27: "EASEINBACK",
|
||||
28: "EASEOUTBACK", 29: "EASEINOUTBACK", 30: "EASEINELASTIC", 31: "EASEOUTELASTIC",
|
||||
32: "EASEINOUTELASTIC", 33: "EASEINBOUNCE", 34: "EASEOUTBOUNCE", 35: "EASEINOUTBOUNCE",
|
||||
}
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, data):
|
||||
self.data, self.pos = data, 0
|
||||
|
||||
def take(self, fmt):
|
||||
values = struct.unpack_from(">" + fmt, self.data, self.pos)
|
||||
self.pos += struct.calcsize(">" + fmt)
|
||||
return values[0] if len(values) == 1 else values
|
||||
|
||||
def bool(self):
|
||||
return self.take("b") != 0
|
||||
|
||||
def string(self):
|
||||
size = self.take("i")
|
||||
text = self.data[self.pos:self.pos + size].decode("utf-8")
|
||||
self.pos += size
|
||||
return text
|
||||
|
||||
|
||||
def read_channel(r, version, keyframe_size):
|
||||
"""[(tick, value, easing id)], or None when the channel is off."""
|
||||
if version >= 2:
|
||||
enabled, count = r.bool(), r.take("i")
|
||||
else:
|
||||
count = r.take("i")
|
||||
enabled = count >= 0
|
||||
if not enabled:
|
||||
r.pos += max(count, 0) * keyframe_size
|
||||
return None
|
||||
keys = []
|
||||
for _ in range(count):
|
||||
start = r.pos
|
||||
tick, value, easing = r.take("ifb")
|
||||
keys.append((tick, value, easing))
|
||||
r.pos = start + keyframe_size
|
||||
return keys
|
||||
|
||||
|
||||
def read_part(r, name, version, keyframe_size):
|
||||
channels = {axis: read_channel(r, version, keyframe_size) for axis in AXES}
|
||||
# Every part but the head (and held items) has bend channels.
|
||||
if name not in ("head", "left_item", "right_item", "leftItem", "rightItem"):
|
||||
for axis in BEND_AXES:
|
||||
channels[axis] = read_channel(r, version, keyframe_size)
|
||||
if version >= 3:
|
||||
for axis in SCALE_AXES:
|
||||
channels[axis] = read_channel(r, version, keyframe_size)
|
||||
return {axis: keys for axis, keys in channels.items() if keys}
|
||||
|
||||
|
||||
def read_animation(data, version):
|
||||
r = Reader(data)
|
||||
r.take("i") # the tick the packet was sent at
|
||||
begin, end, stop = r.take("iii")
|
||||
loop = r.bool()
|
||||
return_tick = r.take("i")
|
||||
ease_before = r.bool()
|
||||
r.bool() # a tag nothing reads
|
||||
keyframe_size = r.take("b")
|
||||
parts = {}
|
||||
if version >= 2:
|
||||
for _ in range(r.take("i")):
|
||||
name = r.string()
|
||||
parts[name] = read_part(r, name, version, keyframe_size)
|
||||
else:
|
||||
for name in V1_PARTS:
|
||||
parts[name] = read_part(r, name, version, keyframe_size)
|
||||
return {"begin": begin, "end": end, "stop": stop, "loop": loop, "return": return_tick,
|
||||
"ease_before": ease_before, "parts": {n: p for n, p in parts.items() if p}}
|
||||
|
||||
|
||||
def plain_text(component):
|
||||
"""The header's strings are Minecraft text components in JSON: a quoted string, or an object
|
||||
with "text", or a translation with a "fallback"."""
|
||||
try:
|
||||
value = json.loads(component)
|
||||
except ValueError:
|
||||
return component
|
||||
if isinstance(value, dict):
|
||||
return value.get("text") or value.get("fallback") or value.get("translate", "")
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
|
||||
def read_file(path):
|
||||
r = Reader(Path(path).read_bytes())
|
||||
r.take("i") # network version
|
||||
r.take("b") # purpose
|
||||
info, animation, skipped = {}, None, []
|
||||
for _ in range(r.take("B")):
|
||||
packet, version, size = r.take("bbi")
|
||||
data = r.data[r.pos:r.pos + size]
|
||||
r.pos += size
|
||||
if packet == ANIMATION:
|
||||
animation = read_animation(data, version)
|
||||
elif packet == HEADER:
|
||||
h = Reader(data)
|
||||
info = {key: plain_text(h.string()) for key in ("name", "description", "author")}
|
||||
else:
|
||||
skipped.append(packet)
|
||||
if animation is None:
|
||||
raise ValueError(f"{path}: no legacy animation sub-packet (maybe the newer format)")
|
||||
return info, animation, skipped
|
||||
|
||||
|
||||
def to_json(info, animation):
|
||||
parts = {name: dict(channels) for name, channels in animation["parts"].items()}
|
||||
body = parts.get("body", {})
|
||||
for axis in BEND_AXES:
|
||||
if axis in body:
|
||||
parts.setdefault("torso", {})[axis] = body.pop(axis)
|
||||
moves = {}
|
||||
for name, channels in parts.items():
|
||||
for axis, keys in channels.items():
|
||||
if axis in SCALE_AXES:
|
||||
continue
|
||||
for tick, value, easing in keys:
|
||||
move = moves.setdefault((tick, EASINGS.get(easing, "LINEAR")), {})
|
||||
move.setdefault(name, {})[axis] = round(value, 6)
|
||||
return {
|
||||
"version": 3,
|
||||
"name": info.get("name", ""),
|
||||
"description": info.get("description", ""),
|
||||
"author": info.get("author", ""),
|
||||
"emote": {
|
||||
"beginTick": animation["begin"],
|
||||
"endTick": animation["end"],
|
||||
"stopTick": animation["stop"],
|
||||
"isLoop": animation["loop"],
|
||||
"returnTick": animation["return"],
|
||||
"easeBeforeKeyframe": animation["ease_before"],
|
||||
"degrees": False,
|
||||
"moves": [{"tick": tick, "easing": easing, **move} for (tick, easing), move in sorted(moves.items())],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def emote_id(name):
|
||||
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "emote"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("files", nargs="+")
|
||||
parser.add_argument("--out-dir")
|
||||
args = parser.parse_args()
|
||||
failed = False
|
||||
for path in args.files:
|
||||
try:
|
||||
info, animation, skipped = read_file(path)
|
||||
except Exception as e: # report and go on to the next file
|
||||
print(f"{path}: {e}", file=sys.stderr)
|
||||
failed = True
|
||||
continue
|
||||
name = info.get("name") or Path(path).stem
|
||||
# The file's name, since emote names can be in any language.
|
||||
out = Path(args.out_dir or Path(path).parent) / f"{emote_id(Path(path).stem)}.json"
|
||||
out.write_text(json.dumps(to_json(info, animation), indent=2))
|
||||
bends = sorted(n for n, c in animation["parts"].items() if "bend" in c)
|
||||
scaled = sorted(n for n, c in animation["parts"].items() if any(a in c for a in SCALE_AXES))
|
||||
print(f"{Path(path).name} -> {out.name}: '{name}' by {info.get('author', '?')}, "
|
||||
f"ticks {animation['begin']}-{animation['end']} (stop {animation['stop']}), "
|
||||
f"{'loops' if animation['loop'] else 'once'}, parts {sorted(animation['parts'])}, "
|
||||
f"bends {bends or 'none'}" + (f", scale on {scaled} (dropped)" if scaled else ""))
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user