From 2ef8c5761f11593db16514a3afdb6578f7c38abd Mon Sep 17 00:00:00 2001 From: Alexander Parent Date: Mon, 6 Jul 2026 17:21:05 -0400 Subject: [PATCH 1/6] build: update to Minestom 26.1.2, fix JUnit runner, drop mql dependency - Bump Minestom to 2026.07.01-26.1.2 (from 1.21.11). - Add the junit-bom + junit-platform-launcher so the test task can actually run (it was missing the launcher, so no JUnit tests could execute). - Remove the dev.hollowcube:mql dependency (replaced by an in-house Molang evaluator). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FsZs24bSHE9fCDzFbwyhuH --- build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d601ab1..a10865d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,8 +39,10 @@ publishing { } dependencies { + testImplementation(platform("org.junit:junit-bom:6.0.0-M2")) testImplementation(libs.junit.api) testRuntimeOnly(libs.junit.engine) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") compileOnly(libs.minestom) testImplementation(libs.minestom) @@ -50,8 +52,6 @@ dependencies { implementation(libs.javax.json.api) implementation(libs.javax.json) - - implementation(libs.mql) } tasks.test { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b207e30..953eada 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,10 @@ [versions] java = "25" junit = "6.0.0-M2" -minestom = "2026.04.13-1.21.11" +minestom = "2026.07.01-26.1.2" commons-io = "2.20.0" zt-zip = "1.17" javax-json = "1.1.4" -mql = "1.0.1" [libraries] minestom = { module = "net.minestom:minestom", version.ref = "minestom" } @@ -15,4 +14,3 @@ commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" } zt-zip = { module = "org.zeroturnaround:zt-zip", version.ref = "zt-zip" } javax-json-api = { module = "javax.json:javax.json-api", version.ref = "javax-json" } javax-json = { module = "org.glassfish:javax.json", version.ref = "javax-json" } -mql = { module = "dev.hollowcube:mql", version.ref = "mql" } From 2a845f8b30aab558cc2975c239932745926b2154 Mon Sep 17 00:00:00 2001 From: Alexander Parent Date: Mon, 6 Jul 2026 17:21:06 -0400 Subject: [PATCH 2/6] mql: replace the broken JIT with a dependency-free Molang evaluator The dev.hollowcube:mql JIT reflectively calls ClassLoader.defineClass, which strong encapsulation blocks on Java 16+, so every Molang keyframe expression threw InaccessibleObjectException on the Java 25 target (unnoticed only because the bundled demos use literal keyframes). Molang.java is a small recursive-descent evaluator (arithmetic, comparisons, && || !, ternary, query./q. namespace, math.* in degrees; case-insensitive; unknown -> 0; parse errors degrade to 0). Compiled expressions are cached. MQLEvaluator/MQLData drop the lib annotations. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FsZs24bSHE9fCDzFbwyhuH --- .../net/worldseed/multipart/mql/MQLData.java | 29 ++- .../worldseed/multipart/mql/MQLEvaluator.java | 6 +- .../net/worldseed/multipart/mql/MQLPoint.java | 13 +- .../net/worldseed/multipart/mql/Molang.java | 220 ++++++++++++++++++ .../worldseed/multipart/mql/MqlPointTest.java | 48 ++++ 5 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 src/main/java/net/worldseed/multipart/mql/Molang.java create mode 100644 src/test/java/net/worldseed/multipart/mql/MqlPointTest.java diff --git a/src/main/java/net/worldseed/multipart/mql/MQLData.java b/src/main/java/net/worldseed/multipart/mql/MQLData.java index 0a3feda..f4f174c 100644 --- a/src/main/java/net/worldseed/multipart/mql/MQLData.java +++ b/src/main/java/net/worldseed/multipart/mql/MQLData.java @@ -1,16 +1,39 @@ package net.worldseed.multipart.mql; -import net.hollowcube.mql.foreign.Query; - +/** + * The Molang query environment exposed to keyframe expressions as {@code query.*} / {@code q.*}, read by the + * {@link Molang} evaluator. {@code life_time} is threaded when available and otherwise approximated by + * {@code anim_time}. + */ public class MQLData { private double time; + private double lifeTime; public void setTime(double time) { this.time = time; } - @Query + public void setLifeTime(double lifeTime) { + this.lifeTime = lifeTime; + } + + /** Seconds since the current animation started (resets each loop). */ public double anim_time() { return time; } + + /** Alias of {@link #anim_time()}. */ + public double time() { + return time; + } + + /** Seconds since the model spawned (does not reset on loop); approximated by anim_time if not supplied. */ + public double life_time() { + return lifeTime; + } + + /** Length of one server tick in seconds. */ + public double delta_time() { + return 1.0 / 20.0; + } } diff --git a/src/main/java/net/worldseed/multipart/mql/MQLEvaluator.java b/src/main/java/net/worldseed/multipart/mql/MQLEvaluator.java index f3b2de0..ad11847 100644 --- a/src/main/java/net/worldseed/multipart/mql/MQLEvaluator.java +++ b/src/main/java/net/worldseed/multipart/mql/MQLEvaluator.java @@ -1,7 +1,7 @@ package net.worldseed.multipart.mql; -import net.hollowcube.mql.jit.MqlEnv; - +/** A compiled Molang keyframe expression. Pure and reusable — the {@link MQLData} is supplied per call. */ +@FunctionalInterface public interface MQLEvaluator { - double evaluate(@MqlEnv({"q", "query"}) MQLData data); + double evaluate(MQLData data); } diff --git a/src/main/java/net/worldseed/multipart/mql/MQLPoint.java b/src/main/java/net/worldseed/multipart/mql/MQLPoint.java index c13a014..3132756 100644 --- a/src/main/java/net/worldseed/multipart/mql/MQLPoint.java +++ b/src/main/java/net/worldseed/multipart/mql/MQLPoint.java @@ -3,7 +3,6 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import net.hollowcube.mql.jit.MqlCompiler; import net.minestom.server.coordinate.Point; import net.minestom.server.coordinate.Vec; @@ -93,15 +92,19 @@ static MQLEvaluator fromDouble(double value) throws InvocationTargetException, N return fromString(Double.toString(value)); } + // Compile each expression once into a reusable, pure Molang evaluator (case handled inside Molang) and + // cache by source so identical keyframe expressions across a model share one compiled tree. + private static final java.util.Map COMPILE_CACHE = new java.util.concurrent.ConcurrentHashMap<>(); + static MQLEvaluator fromString(String s) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { - if (s == null || s.isBlank()) return fromDouble(0); - MqlCompiler compiler = new MqlCompiler<>(MQLEvaluator.class); - Class scriptClass = compiler.compile(s.trim().replace("Math", "math")); - return scriptClass.getDeclaredConstructor().newInstance(); + if (s == null || s.isBlank()) return env -> 0; + // key by the lower-cased source so case-only variants (Math./math., Query./query.) share one compiled tree + return COMPILE_CACHE.computeIfAbsent(s.trim().toLowerCase(java.util.Locale.ROOT), Molang::compile); } public Point evaluate(double time) { data.setTime(time); + data.setLifeTime(time); // approximated by anim_time until a real life time is threaded through double evaluatedX = x; if (molangX != null) { diff --git a/src/main/java/net/worldseed/multipart/mql/Molang.java b/src/main/java/net/worldseed/multipart/mql/Molang.java new file mode 100644 index 0000000..9993508 --- /dev/null +++ b/src/main/java/net/worldseed/multipart/mql/Molang.java @@ -0,0 +1,220 @@ +package net.worldseed.multipart.mql; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * A small, dependency-free Molang evaluator for Blockbench keyframe expressions. + * + *

Replaces the JIT-based {@code dev.hollowcube:mql}, whose runtime class generation reflectively calls + * {@code ClassLoader.defineClass} — blocked by strong encapsulation on Java 16+, so it throws + * {@code InaccessibleObjectException} on WSEE's Java 25 target and Molang keyframes never worked. + * + *

Grammar (case-insensitive; trig in DEGREES, per Bedrock): numbers, {@code + - * /} and unary {@code -}, + * parentheses, comparisons {@code < > <= >= == !=}, logical {@code && || !}, the ternary {@code a ? b : c}, + * the {@code query.}/{@code q.} namespace (anim_time, life_time, delta_time, time) and the {@code math.*} + * functions Blockbench emits. Unknown identifiers evaluate to 0. Compiled expressions are pure and reusable. + */ +final class Molang { + + static MQLEvaluator compile(String source) { + try { + return new Parser(source.toLowerCase(Locale.ROOT)).parseProgram(); + } catch (RuntimeException parseError) { + return env -> 0; // graceful: a malformed expression contributes 0 rather than crashing the model + } + } + + private Molang() { + } + + private static final class Parser { + private final String s; + private int i = 0; + + Parser(String s) { + this.s = s; + } + + MQLEvaluator parseProgram() { + MQLEvaluator e = ternary(); + skipWs(); + // Molang allows ';'-separated statements; keyframes are a single expression, but evaluate the last. + while (i < s.length() && s.charAt(i) == ';') { + i++; + skipWs(); + if (i < s.length()) e = ternary(); + skipWs(); + } + return e; + } + + private MQLEvaluator ternary() { + MQLEvaluator cond = or(); + skipWs(); + if (match('?')) { + MQLEvaluator a = ternary(); + skipWs(); + expect(':'); + MQLEvaluator b = ternary(); + return env -> cond.evaluate(env) != 0 ? a.evaluate(env) : b.evaluate(env); + } + return cond; + } + + private MQLEvaluator or() { + MQLEvaluator left = and(); + while (true) { + skipWs(); + if (match2('|', '|')) { + MQLEvaluator l = left, r = and(); + left = env -> (l.evaluate(env) != 0 || r.evaluate(env) != 0) ? 1 : 0; + } else return left; + } + } + + private MQLEvaluator and() { + MQLEvaluator left = comparison(); + while (true) { + skipWs(); + if (match2('&', '&')) { + MQLEvaluator l = left, r = comparison(); + left = env -> (l.evaluate(env) != 0 && r.evaluate(env) != 0) ? 1 : 0; + } else return left; + } + } + + private MQLEvaluator comparison() { + MQLEvaluator l = additive(); + skipWs(); + if (match2('=', '=')) { MQLEvaluator r = additive(); return env -> l.evaluate(env) == r.evaluate(env) ? 1 : 0; } + if (match2('!', '=')) { MQLEvaluator r = additive(); return env -> l.evaluate(env) != r.evaluate(env) ? 1 : 0; } + if (match2('<', '=')) { MQLEvaluator r = additive(); return env -> l.evaluate(env) <= r.evaluate(env) ? 1 : 0; } + if (match2('>', '=')) { MQLEvaluator r = additive(); return env -> l.evaluate(env) >= r.evaluate(env) ? 1 : 0; } + if (match('<')) { MQLEvaluator r = additive(); return env -> l.evaluate(env) < r.evaluate(env) ? 1 : 0; } + if (match('>')) { MQLEvaluator r = additive(); return env -> l.evaluate(env) > r.evaluate(env) ? 1 : 0; } + return l; + } + + private MQLEvaluator additive() { + MQLEvaluator left = multiplicative(); + while (true) { + skipWs(); + if (match('+')) { MQLEvaluator l = left, r = multiplicative(); left = env -> l.evaluate(env) + r.evaluate(env); } + else if (match('-')) { MQLEvaluator l = left, r = multiplicative(); left = env -> l.evaluate(env) - r.evaluate(env); } + else return left; + } + } + + private MQLEvaluator multiplicative() { + MQLEvaluator left = unary(); + while (true) { + skipWs(); + if (match('*')) { MQLEvaluator l = left, r = unary(); left = env -> l.evaluate(env) * r.evaluate(env); } + else if (match('/')) { MQLEvaluator l = left, r = unary(); left = env -> { double d = r.evaluate(env); return d == 0 ? 0 : l.evaluate(env) / d; }; } + else return left; + } + } + + private MQLEvaluator unary() { + skipWs(); + if (match('-')) { MQLEvaluator e = unary(); return env -> -e.evaluate(env); } + if (match('!')) { MQLEvaluator e = unary(); return env -> e.evaluate(env) == 0 ? 1 : 0; } + return primary(); + } + + private MQLEvaluator primary() { + skipWs(); + char c = peek(); + if (c == '(') { + i++; + MQLEvaluator e = ternary(); + skipWs(); + expect(')'); + return e; + } + if (isDigit(c) || c == '.') return number(); + if (isIdentStart(c)) return identifier(); + throw new RuntimeException("unexpected '" + c + "' at " + i); + } + + private MQLEvaluator number() { + int start = i; + while (i < s.length() && (isDigit(s.charAt(i)) || s.charAt(i) == '.')) i++; + double value = Double.parseDouble(s.substring(start, i)); + return env -> value; + } + + private MQLEvaluator identifier() { + int start = i; + while (i < s.length() && (isIdentPart(s.charAt(i)) || s.charAt(i) == '.')) i++; + String name = s.substring(start, i); + skipWs(); + if (peek() == '(') { + i++; + List args = new ArrayList<>(); + skipWs(); + if (peek() != ')') { + args.add(ternary()); + skipWs(); + while (match(',')) { args.add(ternary()); skipWs(); } + } + expect(')'); + return function(name, args); + } + return variable(name); + } + + private static MQLEvaluator variable(String name) { + return switch (name) { + case "query.anim_time", "q.anim_time", "query.time", "q.time" -> env -> env.anim_time(); + case "query.life_time", "q.life_time" -> env -> env.life_time(); + case "query.delta_time", "q.delta_time" -> env -> env.delta_time(); + case "math.pi" -> env -> Math.PI; + case "true" -> env -> 1; + case "false" -> env -> 0; + default -> env -> 0; // unknown query/variable -> 0 (Bedrock-style) + }; + } + + private static MQLEvaluator function(String name, List a) { + return switch (name) { + case "math.sin" -> env -> Math.sin(Math.toRadians(a.get(0).evaluate(env))); + case "math.cos" -> env -> Math.cos(Math.toRadians(a.get(0).evaluate(env))); + case "math.tan" -> env -> Math.tan(Math.toRadians(a.get(0).evaluate(env))); + case "math.asin" -> env -> Math.toDegrees(Math.asin(a.get(0).evaluate(env))); + case "math.acos" -> env -> Math.toDegrees(Math.acos(a.get(0).evaluate(env))); + case "math.atan" -> env -> Math.toDegrees(Math.atan(a.get(0).evaluate(env))); + case "math.atan2" -> env -> Math.toDegrees(Math.atan2(a.get(0).evaluate(env), a.get(1).evaluate(env))); + case "math.abs" -> env -> Math.abs(a.get(0).evaluate(env)); + case "math.sqrt" -> env -> Math.sqrt(a.get(0).evaluate(env)); + case "math.pow" -> env -> Math.pow(a.get(0).evaluate(env), a.get(1).evaluate(env)); + case "math.exp" -> env -> Math.exp(a.get(0).evaluate(env)); + case "math.ln" -> env -> Math.log(a.get(0).evaluate(env)); + case "math.mod" -> env -> { double d = a.get(1).evaluate(env); return d == 0 ? 0 : a.get(0).evaluate(env) % d; }; + case "math.min" -> env -> Math.min(a.get(0).evaluate(env), a.get(1).evaluate(env)); + case "math.max" -> env -> Math.max(a.get(0).evaluate(env), a.get(1).evaluate(env)); + case "math.floor" -> env -> Math.floor(a.get(0).evaluate(env)); + case "math.ceil" -> env -> Math.ceil(a.get(0).evaluate(env)); + case "math.round" -> env -> Math.round(a.get(0).evaluate(env)); + case "math.trunc" -> env -> (double) (long) a.get(0).evaluate(env); + case "math.sign" -> env -> Math.signum(a.get(0).evaluate(env)); + case "math.clamp" -> env -> Math.max(a.get(1).evaluate(env), Math.min(a.get(2).evaluate(env), a.get(0).evaluate(env))); + case "math.lerp" -> env -> { double x = a.get(0).evaluate(env), y = a.get(1).evaluate(env), t = a.get(2).evaluate(env); return x + (y - x) * t; }; + case "math.random" -> env -> { double lo = a.get(0).evaluate(env), hi = a.get(1).evaluate(env); return lo + Math.random() * (hi - lo); }; + default -> env -> 0; + }; + } + + private void skipWs() { while (i < s.length() && Character.isWhitespace(s.charAt(i))) i++; } + private char peek() { return i < s.length() ? s.charAt(i) : '\0'; } + private boolean match(char c) { skipWs(); if (peek() == c) { i++; return true; } return false; } + private boolean match2(char a, char b) { skipWs(); if (i + 1 < s.length() && s.charAt(i) == a && s.charAt(i + 1) == b) { i += 2; return true; } return false; } + private void expect(char c) { if (!match(c)) throw new RuntimeException("expected '" + c + "' at " + i); } + + private static boolean isDigit(char c) { return c >= '0' && c <= '9'; } + private static boolean isIdentStart(char c) { return c == '_' || (c >= 'a' && c <= 'z'); } + private static boolean isIdentPart(char c) { return isIdentStart(c) || isDigit(c); } + } +} diff --git a/src/test/java/net/worldseed/multipart/mql/MqlPointTest.java b/src/test/java/net/worldseed/multipart/mql/MqlPointTest.java new file mode 100644 index 0000000..fe51fc1 --- /dev/null +++ b/src/test/java/net/worldseed/multipart/mql/MqlPointTest.java @@ -0,0 +1,48 @@ +package net.worldseed.multipart.mql; + +import com.google.gson.JsonObject; +import net.minestom.server.coordinate.Point; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** Molang keyframe expressions: case-insensitive namespace handling + compiled-expression caching. */ +class MqlPointTest { + + @Test + void namespacesAreCaseInsensitiveAndEvaluate() throws Exception { + JsonObject json = new JsonObject(); + json.addProperty("x", "Query.anim_time"); // capital Q normalizes to query. + json.addProperty("y", "q.anim_time"); // short form + json.addProperty("z", "2 + 3"); // literal arithmetic + + Point result = new MQLPoint(json).evaluate(4.0); + + assertEquals(4.0, result.x(), 1e-9); + assertEquals(4.0, result.y(), 1e-9); + assertEquals(5.0, result.z(), 1e-9); + } + + @Test + void identicalExpressionsShareOneCompiledEvaluator() throws Exception { + MQLEvaluator a = MQLPoint.fromString("query.anim_time * 2"); + MQLEvaluator b = MQLPoint.fromString("Query.anim_time * 2"); // same after case-normalization + + assertSame(a, b, "identical (case-insensitive) expressions must reuse the cached compiled class"); + } + + @Test + void mathTernaryAndPrecedence() throws Exception { + MQLData env = new MQLData(); + env.setTime(2.0); + + assertEquals(1.0, MQLPoint.fromString("math.cos(0)").evaluate(env), 1e-9); + assertEquals(1.0, MQLPoint.fromString("math.sin(90)").evaluate(env), 1e-9, "trig is in degrees"); + assertEquals(14.0, MQLPoint.fromString("2 + 3 * 4").evaluate(env), 1e-9, "precedence"); + assertEquals(10.0, MQLPoint.fromString("query.anim_time > 1 ? 10 : 20").evaluate(env), 1e-9); + + env.setTime(0.0); + assertEquals(20.0, MQLPoint.fromString("query.anim_time > 1 ? 10 : 20").evaluate(env), 1e-9); + } +} From 33973d1f5e17597561c94a8b16e8c064e4a2827e Mon Sep 17 00:00:00 2001 From: Alexander Parent Date: Mon, 6 Jul 2026 17:21:25 -0400 Subject: [PATCH 3/6] animation: faithful interpolation, effects, loop modes, blending, perf - Fix a translation bug: the animation offset was divided by 4 twice (frame provider + display), so position keyframes moved parts 1/4 as far as authored. - Rewrite Interpolator to real Blockbench step / linear / Catmull-Rom for all channels (was linear-only with a broken 2-point slerp); bezier approximates as a smooth spline; support discontinuous keyframes (pre/post data points). - Play Blockbench sound/particle/timeline effects (AnimationEffect + AnimationEffectHandler) with a default handler + setEffectHandler override. - Respect the loop flag: non-looping animations hold their last frame. - Animation blending: per-animation weight that ramps; playRepeat(name, blendTicks) crossfades in/out; per-bone transforms combine by weight. - Perf: dirty-check aside, memoize propagated + world rotation/scale per draw so the hot path stops re-walking the parent chain; fix a ~20x frame-cache over-alloc. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FsZs24bSHE9fCDzFbwyhuH --- .../multipart/animations/AnimationEffect.java | 17 ++ .../animations/AnimationEffectHandler.java | 14 ++ .../animations/AnimationHandler.java | 23 +++ .../animations/AnimationHandlerImpl.java | 170 +++++++++++++++++- .../multipart/animations/BoneAnimation.java | 4 + .../animations/BoneAnimationImpl.java | 35 +++- .../animations/CachedFrameProvider.java | 10 +- .../animations/ComputedFrameProvider.java | 4 +- .../multipart/animations/Interpolator.java | 151 +++++++--------- .../multipart/animations/ModelAnimation.java | 28 +++ .../animations/ModelAnimationClassic.java | 63 ++++++- .../multipart/model_bones/ModelBoneImpl.java | 82 ++++++--- .../display_entity/ModelBoneHeadDisplay.java | 2 +- .../generator/AnimationGenerator.java | 49 ++++- .../animations/AnimationEffectsTest.java | 65 +++++++ .../animations/InterpolatorTest.java | 76 ++++++++ 16 files changed, 664 insertions(+), 129 deletions(-) create mode 100644 src/main/java/net/worldseed/multipart/animations/AnimationEffect.java create mode 100644 src/main/java/net/worldseed/multipart/animations/AnimationEffectHandler.java create mode 100644 src/test/java/net/worldseed/multipart/animations/AnimationEffectsTest.java create mode 100644 src/test/java/net/worldseed/multipart/animations/InterpolatorTest.java diff --git a/src/main/java/net/worldseed/multipart/animations/AnimationEffect.java b/src/main/java/net/worldseed/multipart/animations/AnimationEffect.java new file mode 100644 index 0000000..4e97471 --- /dev/null +++ b/src/main/java/net/worldseed/multipart/animations/AnimationEffect.java @@ -0,0 +1,17 @@ +package net.worldseed.multipart.animations; + +/** + * A sound / particle / timeline effect authored on a Blockbench animation's "effects" track, resolved to a + * playback tick. Fired by the {@link AnimationHandler} while the owning animation plays (see + * {@link AnimationEffectHandler}). + * + * @param animation the animation this effect belongs to + * @param type SOUND, PARTICLE or TIMELINE + * @param tick when it fires, in ticks from the start of the animation (Blockbench time * 20) + * @param effect the effect id — a sound key (SOUND) or particle id (PARTICLE); null for TIMELINE + * @param locator the locator/VFX bone the effect is anchored to, or null for the model origin + * @param script the Molang/script instruction (TIMELINE, and optional particle script), or null + */ +public record AnimationEffect(String animation, Type type, int tick, String effect, String locator, String script) { + public enum Type { SOUND, PARTICLE, TIMELINE } +} diff --git a/src/main/java/net/worldseed/multipart/animations/AnimationEffectHandler.java b/src/main/java/net/worldseed/multipart/animations/AnimationEffectHandler.java new file mode 100644 index 0000000..50c170a --- /dev/null +++ b/src/main/java/net/worldseed/multipart/animations/AnimationEffectHandler.java @@ -0,0 +1,14 @@ +package net.worldseed.multipart.animations; + +import net.worldseed.multipart.GenericModel; + +/** + * Handles a Blockbench animation effect (sound / particle / timeline) when it fires. Set one on an + * {@link AnimationHandler} via {@link AnimationHandler#setEffectHandler} to map effect ids to your own + * content; the default handler ({@link AnimationHandler#DEFAULT_EFFECT_HANDLER}) plays the sound / particle + * whose id is a valid Minecraft key at the effect's locator (or the model origin) for the model's viewers. + */ +@FunctionalInterface +public interface AnimationEffectHandler { + void onEffect(GenericModel model, AnimationEffect effect); +} diff --git a/src/main/java/net/worldseed/multipart/animations/AnimationHandler.java b/src/main/java/net/worldseed/multipart/animations/AnimationHandler.java index 536a3e6..e510e90 100644 --- a/src/main/java/net/worldseed/multipart/animations/AnimationHandler.java +++ b/src/main/java/net/worldseed/multipart/animations/AnimationHandler.java @@ -19,6 +19,17 @@ public interface AnimationHandler { void playRepeat(String animation, AnimationDirection direction) throws IllegalArgumentException; + /** + * Crossfade to a repeating animation over {@code blendTicks} ticks: it blends in while whatever is + * currently playing blends out (weighted blend). {@code blendTicks <= 0} is a hard swap. + * + * @param animation name of the animation to blend to + * @param blendTicks blend duration in ticks + */ + void playRepeat(String animation, int blendTicks) throws IllegalArgumentException; + + void playRepeat(String animation, AnimationDirection direction, int blendTicks) throws IllegalArgumentException; + /** * Stop a repeating animation * @@ -75,6 +86,18 @@ public interface AnimationHandler { Map animationPriorities(); + /** The built-in effect handler: plays the sound / particle whose id is a valid Minecraft key at the + * effect's locator (or model origin) for the model's viewers. Timeline effects are ignored. */ + AnimationEffectHandler DEFAULT_EFFECT_HANDLER = AnimationHandlerImpl::playDefaultEffect; + + /** + * Set how Blockbench animation effects (sound / particle / timeline) are handled when they fire. + * Replace the {@link #DEFAULT_EFFECT_HANDLER} to map effect ids to your own sounds/particles/logic. + * + * @param handler the handler, or null to ignore all effects + */ + void setEffectHandler(AnimationEffectHandler handler); + enum AnimationDirection { FORWARD, BACKWARD, diff --git a/src/main/java/net/worldseed/multipart/animations/AnimationHandlerImpl.java b/src/main/java/net/worldseed/multipart/animations/AnimationHandlerImpl.java index 38047d5..eabd9bc 100644 --- a/src/main/java/net/worldseed/multipart/animations/AnimationHandlerImpl.java +++ b/src/main/java/net/worldseed/multipart/animations/AnimationHandlerImpl.java @@ -2,7 +2,13 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.network.packet.server.play.ParticlePacket; +import net.minestom.server.particle.Particle; import net.minestom.server.timer.ExecutionType; import net.minestom.server.timer.Task; import net.minestom.server.timer.TaskSchedule; @@ -24,6 +30,10 @@ public class AnimationHandlerImpl implements AnimationHandler { private final Map callbacks = new ConcurrentHashMap<>(); private final Map callbackTimers = new ConcurrentHashMap<>(); + private final Map> effectsByAnimation = new ConcurrentHashMap<>(); + private final Map lastEffectTick = new ConcurrentHashMap<>(); + private volatile AnimationEffectHandler effectHandler = AnimationHandler.DEFAULT_EFFECT_HANDLER; + public AnimationHandlerImpl(GenericModel model) { this.model = model; loadDefaultAnimations(); @@ -44,6 +54,8 @@ protected void loadDefaultAnimations() { public void registerAnimation(String name, JsonElement animation, int priority) { final JsonElement animationLength = animation.getAsJsonObject().get("animation_length"); final double length = animationLength == null ? 0 : animationLength.getAsDouble(); + final JsonElement loopElement = animation.getAsJsonObject().get("loop"); + final boolean looping = loopElement != null && loopElement.getAsBoolean(); HashSet animationSet = new HashSet<>(); HashSet animatedBones = new HashSet<>(); @@ -61,17 +73,17 @@ public void registerAnimation(String name, JsonElement animation, int priority) if (animationRotation != null) { animated = true; - BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationRotation, ModelLoader.AnimationType.ROTATION, length); + BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationRotation, ModelLoader.AnimationType.ROTATION, length, looping); animationSet.add(boneAnimation); } if (animationPosition != null) { animated = true; - BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationPosition, ModelLoader.AnimationType.TRANSLATION, length); + BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationPosition, ModelLoader.AnimationType.TRANSLATION, length, looping); animationSet.add(boneAnimation); } if (animationScale != null) { animated = true; - BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationScale, ModelLoader.AnimationType.SCALE, length); + BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationScale, ModelLoader.AnimationType.SCALE, length, looping); animationSet.add(boneAnimation); } @@ -80,7 +92,34 @@ public void registerAnimation(String name, JsonElement animation, int priority) } } - animations.put(name, new ModelAnimationClassic(name, (int) (length * 20), priority, animationSet, animatedBones)); + animations.put(name, new ModelAnimationClassic(name, (int) (length * 20), priority, animationSet, animatedBones, looping)); + + // parse Blockbench sound/particle/timeline effects for this animation + List effects = new ArrayList<>(); + JsonElement effectsJson = animation.getAsJsonObject().get("effects"); + if (effectsJson != null && effectsJson.isJsonArray()) { + for (JsonElement el : effectsJson.getAsJsonArray()) { + JsonObject o = el.getAsJsonObject(); + String channel = o.has("channel") ? o.get("channel").getAsString() : ""; + AnimationEffect.Type type = switch (channel) { + case "sound" -> AnimationEffect.Type.SOUND; + case "particle" -> AnimationEffect.Type.PARTICLE; + default -> AnimationEffect.Type.TIMELINE; + }; + int tick = (int) Math.round(o.get("time").getAsDouble() * 20); + effects.add(new AnimationEffect(name, type, tick, + o.has("effect") ? o.get("effect").getAsString() : null, + o.has("locator") ? o.get("locator").getAsString() : null, + o.has("script") ? o.get("script").getAsString() : null)); + } + effects.sort(Comparator.comparingInt(AnimationEffect::tick)); + } + effectsByAnimation.put(name, effects); + } + + @Override + public void setEffectHandler(AnimationEffectHandler handler) { + this.effectHandler = handler; } @Override @@ -92,6 +131,35 @@ public void playRepeat(String animation) throws IllegalArgumentException { playRepeat(animation, AnimationDirection.FORWARD); } + @Override + public void playRepeat(String animation, int blendTicks) throws IllegalArgumentException { + playRepeat(animation, AnimationDirection.FORWARD, blendTicks); + } + + @Override + public void playRepeat(String animation, AnimationDirection direction, int blendTicks) throws IllegalArgumentException { + if (blendTicks <= 0) { + playRepeat(animation, direction); + return; + } + Integer priority = this.animationPriorities().get(animation); + if (priority == null) throw new IllegalArgumentException("Animation " + animation + " does not exist"); + ModelAnimation modelAnimation = this.animations.get(animation); + + // fade out whatever is currently repeating (except the target) + for (ModelAnimation other : this.repeating.values()) { + if (!other.name().equals(animation)) other.blendTo(0, blendTicks); + } + + modelAnimation.setDirection(direction); + this.repeating.put(priority, modelAnimation); + if (playingOnce == null) { + modelAnimation.setWeight(0); + modelAnimation.play(false); + modelAnimation.blendTo(1, blendTicks); // fade in + } + } + @Override public void playRepeat(String animation, AnimationDirection direction) throws IllegalArgumentException { if (this.animationPriorities().get(animation) == null) @@ -230,11 +298,105 @@ private void tick() { this.animations.forEach((_, animations) -> { animations.tick(); //Play every tick (besides the first one) of the animation }); + + fireEffects(); + + // drop animations that have finished blending out + this.repeating.entrySet().removeIf(entry -> { + if (entry.getValue().fadedOut()) { + entry.getValue().stop(); + return true; + } + return false; + }); } catch (Exception e) { e.printStackTrace(); } } + /** Fire any sound/particle/timeline effects the currently-playing animations crossed this tick. */ + private void fireEffects() { + AnimationEffectHandler handler = this.effectHandler; + if (handler == null) return; + + Set playing = new HashSet<>(repeating.values()); + if (playingOnce != null) { + ModelAnimation once = animations.get(playingOnce); + if (once != null) playing.add(once); + } + + for (ModelAnimation anim : playing) { + List effects = effectsByAnimation.get(anim.name()); + if (effects == null || effects.isEmpty()) continue; + + int cur = anim.currentTick(); + if (cur < 0) { // not actually playing this tick + lastEffectTick.remove(anim.name()); + continue; + } + int last = lastEffectTick.getOrDefault(anim.name(), -1); + if (cur != last) { + for (AnimationEffect effect : effects) { + int t = effect.tick(); + // fire effects whose tick falls in (last, cur], wrapping when the animation looped + boolean crossed = (last < cur) ? (t > last && t <= cur) : (t > last || t <= cur); + if (crossed) { + try { + handler.onEffect(model, effect); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + lastEffectTick.put(anim.name(), cur); + } + } + } + + /** Built-in effect handler (see {@link AnimationHandler#DEFAULT_EFFECT_HANDLER}). */ + public static void playDefaultEffect(GenericModel model, AnimationEffect effect) { + switch (effect.type()) { + case SOUND -> { + if (effect.effect() == null || effect.effect().isBlank()) return; + Key key; + try { + key = Key.key(effect.effect()); + } catch (Exception invalidKey) { + return; // effect id isn't a Minecraft sound key — a custom handler should map it + } + Point pos = effectPosition(model, effect); + Sound sound = Sound.sound(key, Sound.Source.NEUTRAL, 1f, 1f); + model.getViewers().forEach(viewer -> viewer.playSound(sound, pos.x(), pos.y(), pos.z())); + } + case PARTICLE -> { + if (effect.effect() == null || effect.effect().isBlank()) return; + Particle particle; + try { + particle = Particle.fromKey(effect.effect()); + } catch (Exception invalidKey) { + return; + } + if (particle == null) return; // not a vanilla particle — a custom handler should map it + Point pos = effectPosition(model, effect); + ParticlePacket packet = new ParticlePacket(particle, pos, Vec.ZERO, 0f, 1); + model.getViewers().forEach(viewer -> viewer.sendPacket(packet)); + } + case TIMELINE -> { /* script instruction — no built-in behaviour; set a custom handler */ } + } + } + + private static Point effectPosition(GenericModel model, AnimationEffect effect) { + if (effect.locator() != null && !effect.locator().isBlank()) { + try { + Point p = model.getVFX(effect.locator()); + if (p != null) return p; + } catch (Exception ignored) { + // no such locator/VFX bone — fall back to the model origin + } + } + return model.getPosition(); + } + public void destroy() { this.task.cancel(); } diff --git a/src/main/java/net/worldseed/multipart/animations/BoneAnimation.java b/src/main/java/net/worldseed/multipart/animations/BoneAnimation.java index 964f208..a9d0f01 100644 --- a/src/main/java/net/worldseed/multipart/animations/BoneAnimation.java +++ b/src/main/java/net/worldseed/multipart/animations/BoneAnimation.java @@ -25,4 +25,8 @@ public interface BoneAnimation { void tick(); void resume(short tick); short getTick(); + + /** Blend weight in [0,1] applied to this animation's contribution (set by the owning ModelAnimation). */ + default double weight() { return 1.0; } + default void setWeight(double weight) {} } diff --git a/src/main/java/net/worldseed/multipart/animations/BoneAnimationImpl.java b/src/main/java/net/worldseed/multipart/animations/BoneAnimationImpl.java index 6e771db..4b8b86e 100644 --- a/src/main/java/net/worldseed/multipart/animations/BoneAnimationImpl.java +++ b/src/main/java/net/worldseed/multipart/animations/BoneAnimationImpl.java @@ -21,15 +21,18 @@ public class BoneAnimationImpl implements BoneAnimation { private final int length; private final String name; private final String boneName; + private final boolean looping; + private volatile double weight = 1.0; private boolean playing = false; private short tick = 0; private AnimationHandlerImpl.AnimationDirection direction = AnimationHandlerImpl.AnimationDirection.FORWARD; - BoneAnimationImpl(String modelName, String animationName, String boneName, ModelBone bone, JsonElement keyframes, ModelLoader.AnimationType animationType, double length) { + BoneAnimationImpl(String modelName, String animationName, String boneName, ModelBone bone, JsonElement keyframes, ModelLoader.AnimationType animationType, double length, boolean looping) { this.type = animationType; this.length = (int) (length * 20); this.name = animationName; this.boneName = boneName; + this.looping = looping; FrameProvider found; if (this.type == ModelLoader.AnimationType.ROTATION) { @@ -71,14 +74,24 @@ public boolean isPlaying() { return playing; } + @Override + public double weight() { + return weight; + } + + @Override + public void setWeight(double weight) { + this.weight = weight; + } + public void tick() { if (playing) { if (direction == AnimationHandlerImpl.AnimationDirection.FORWARD) { tick++; - if (tick > length && length != 0) tick = 0; + if (tick > length && length != 0) tick = looping ? 0 : (short) length; // loop, or hold last frame } else if (direction == AnimationHandlerImpl.AnimationDirection.BACKWARD) { tick--; - if (tick < 0 && length != 0) tick = (short) length; + if (tick < 0 && length != 0) tick = looping ? (short) length : 0; // loop, or hold first frame } } } @@ -133,10 +146,12 @@ private FrameProvider computeCachedTransforms(JsonElement keyframes) { if (entry.getValue() instanceof JsonObject obj) { if (obj.get("post") instanceof JsonArray arr) { if (arr.get(0) instanceof JsonObject) { - MQLPoint point = ModelEngine.getMQLPos(obj.get("post").getAsJsonArray().get(0)).orElse(MQLPoint.ZERO); + MQLPoint pre = ModelEngine.getMQLPos(arr.get(0)).orElse(MQLPoint.ZERO); + // a discontinuous keyframe carries a second data point = the value leaving it + MQLPoint post = arr.size() > 1 ? ModelEngine.getMQLPos(arr.get(1)).orElse(pre) : pre; String lerp = entry.getValue().getAsJsonObject().get("lerp_mode").getAsString(); if (lerp == null) lerp = "linear"; - transform.put(time, new PointInterpolation(point, lerp)); + transform.put(time, new PointInterpolation(pre, post, lerp)); } else { MQLPoint point = ModelEngine.getMQLPos(obj.get("post").getAsJsonArray()).orElse(MQLPoint.ZERO); String lerp = entry.getValue().getAsJsonObject().get("lerp_mode").getAsString(); @@ -192,6 +207,14 @@ public short getTick() { return tick; } - public record PointInterpolation(MQLPoint p, String lerp) { + /** + * A keyframe's interpolation data. {@code p} is the primary ("pre") value approached from the left; + * {@code post} is the value leaving to the right — different from {@code p} only on a discontinuous + * keyframe (two data points), otherwise the same instance. + */ + public record PointInterpolation(MQLPoint p, MQLPoint post, String lerp) { + public PointInterpolation(MQLPoint p, String lerp) { + this(p, p, lerp); // continuous keyframe: leaving value == approaching value + } } } diff --git a/src/main/java/net/worldseed/multipart/animations/CachedFrameProvider.java b/src/main/java/net/worldseed/multipart/animations/CachedFrameProvider.java index f77ae7d..da310e8 100644 --- a/src/main/java/net/worldseed/multipart/animations/CachedFrameProvider.java +++ b/src/main/java/net/worldseed/multipart/animations/CachedFrameProvider.java @@ -19,11 +19,17 @@ public CachedFrameProvider(int length, LinkedHashMap calculateAllTransforms(double animationTime, LinkedHashMap t, ModelLoader.AnimationType type) { Map transform = new HashMap<>(); - int ticks = (int) (animationTime * 20); + // animationTime is already in ticks (BoneAnimationImpl.length = length_seconds*20). The old + // (animationTime*20) precomputed ~20x too many frames (and could overflow the short tick key + // for long animations). We only ever read ticks 0..length, so cache exactly that range. + int ticks = (int) animationTime; for (int i = 0; i <= ticks; i++) { var p = calculateTransform(i, t, type, animationTime); - if (type == ModelLoader.AnimationType.TRANSLATION) p = p.div(4); + // NOTE: translation stays in model (16-unit) space here, same as the static bone geometry. + // The single div(4) in ModelBonePartDisplay#calculatePositionInternal converts the whole + // accumulated position to display space. A div(4) here too would divide translation twice + // (=> 4x too little movement); see the animated-transform oracle diff vs Blockbench. transform.put((short) i, p); } diff --git a/src/main/java/net/worldseed/multipart/animations/ComputedFrameProvider.java b/src/main/java/net/worldseed/multipart/animations/ComputedFrameProvider.java index 41e223b..f4444ab 100644 --- a/src/main/java/net/worldseed/multipart/animations/ComputedFrameProvider.java +++ b/src/main/java/net/worldseed/multipart/animations/ComputedFrameProvider.java @@ -29,7 +29,9 @@ public Point getFrame(int tick) { var point = first.p().evaluate(toInterpolate); if (type == ModelLoader.AnimationType.ROTATION) return point.mul(RotationMul); - if (type == ModelLoader.AnimationType.TRANSLATION) return point.mul(TranslationMul).mul(0.25); + // translation stays in model (16-unit) space; the single div(4) in the display bone converts it. + // (Removed a second .mul(0.25) that divided translation twice -> 4x too little movement.) + if (type == ModelLoader.AnimationType.TRANSLATION) return point.mul(TranslationMul); return point; } } diff --git a/src/main/java/net/worldseed/multipart/animations/Interpolator.java b/src/main/java/net/worldseed/multipart/animations/Interpolator.java index a67c0c2..96f0e0b 100644 --- a/src/main/java/net/worldseed/multipart/animations/Interpolator.java +++ b/src/main/java/net/worldseed/multipart/animations/Interpolator.java @@ -2,113 +2,94 @@ import net.minestom.server.coordinate.Point; import net.minestom.server.coordinate.Vec; -import net.worldseed.multipart.Quaternion; -import org.jetbrains.annotations.Nullable; +import java.util.ArrayList; import java.util.LinkedHashMap; - +import java.util.List; + +/** + * Blockbench-faithful keyframe interpolation for a single animation channel (rotation / translation / + * scale), evaluated per component. Supports: + *

    + *
  • step – hold the start keyframe value until the next keyframe,
  • + *
  • linear – straight lerp between the two bracketing keyframes,
  • + *
  • catmullrom ("smooth") – uniform Catmull-Rom through the 4-keyframe neighbourhood, + * clamped at the ends. A segment is smooth if either of its endpoint keyframes is + * catmullrom (matches Blockbench and the reference renderer).
  • + *
+ * Bezier keyframes are approximated as linear (their tangent handles are not carried through the + * generated animation JSON). Rotations are interpolated on their Euler components exactly like + * Blockbench – no quaternion slerp – so multi-turn spins and eased rotations match the editor. + */ public class Interpolator { - private static @Nullable StartEnd getStartEnd(double time, LinkedHashMap transform, double animationTime) { - if (transform.isEmpty()) return null; - BoneAnimationImpl.PointInterpolation lastPoint = transform.get(transform.keySet().iterator().next()); - double lastTime = 0; - - for (Double keyTime : transform.keySet()) { - if (keyTime > time) { - return new StartEnd(lastPoint, transform.get(keyTime), lastTime, keyTime); - } - - lastPoint = transform.get(keyTime); - lastTime = keyTime; - } - return new StartEnd(lastPoint, lastPoint, lastTime, animationTime); + static Point interpolateRotation(double time, LinkedHashMap transform, double animationTime) { + return interpolate(time, transform, Vec.ZERO); } - static Quaternion slerp(Quaternion qa, Quaternion qb, double t) { - // quaternion to return - // Calculate angle between them. - double cosHalfTheta = qa.w() * qb.w() + qa.x() * qb.x() + qa.y() * qb.y() + qa.z() * qb.z(); - // if qa=qb or qa=-qb then theta = 0 and we can return qa - if (Math.abs(cosHalfTheta) >= 1.0) { - double qmw = qa.w(); - double qmx = qa.x(); - double qmy = qa.y(); - double qmz = qa.z(); - return new Quaternion(qmx, qmy, qmz, qmw); - } - // Calculate temporary values. - double halfTheta = Math.acos(cosHalfTheta); - double sinHalfTheta = Math.sqrt(1.0 - cosHalfTheta * cosHalfTheta); - // if theta = 180 degrees then result is not fully defined - // we could rotate around any axis normal to qa or qb - if (Math.abs(sinHalfTheta) < 0.001) { // fabs is floating point absolute - double qmw = (qa.w() * 0.5 + qb.w() * 0.5); - double qmx = (qa.x() * 0.5 + qb.x() * 0.5); - double qmy = (qa.y() * 0.5 + qb.y() * 0.5); - double qmz = (qa.z() * 0.5 + qb.z() * 0.5); - return new Quaternion(qmx, qmy, qmz, qmw); - } - double ratioA = Math.sin((1 - t) * halfTheta) / sinHalfTheta; - double ratioB = Math.sin(t * halfTheta) / sinHalfTheta; - //calculate Quaternion. - double qmw = (qa.w() * ratioA + qb.w() * ratioB); - double qmx = (qa.x() * ratioA + qb.x() * ratioB); - double qmy = (qa.y() * ratioA + qb.y() * ratioB); - double qmz = (qa.z() * ratioA + qb.z() * ratioB); - return new Quaternion(qmx, qmy, qmz, qmw); + static Point interpolateTranslation(double time, LinkedHashMap transform, double animationTime) { + return interpolate(time, transform, Vec.ZERO); } - static Point interpolateRotation(double time, LinkedHashMap transform, double animationTime) { - StartEnd points = getStartEnd(time, transform, animationTime); - if (points == null) return Vec.ZERO; + public static Point interpolateScale(double time, LinkedHashMap transform, double animationTime) { + return interpolate(time, transform, Vec.ONE); + } - double timeDiff = points.et - points.st; + private static Point interpolate(double time, LinkedHashMap transform, Point fallback) { + if (transform.isEmpty()) return fallback; + List times = new ArrayList<>(transform.keySet()); // insertion order == ascending keyframe time + int n = times.size(); - if (timeDiff == 0) - return points.s.p().evaluate(time); + // find segment [i, i+1] with times[i] <= time < times[i+1] + int i = 0; + while (i + 1 < n && times.get(i + 1) <= time) i++; - double timePercent = (time - points.st) / timeDiff; + if (time <= times.get(0) || i >= n - 1) return pre(transform, times.get(i), time); // before first / past last -> hold - if (points.s.lerp().equals("linear")) { - Vec ps = points.s.p().evaluate(time).asVec(); - Vec pe = points.e.p().evaluate(time).asVec(); + BoneAnimationImpl.PointInterpolation a = transform.get(times.get(i)); + BoneAnimationImpl.PointInterpolation b = transform.get(times.get(i + 1)); + double ta = times.get(i), tb = times.get(i + 1); + double alpha = tb == ta ? 0 : (time - ta) / (tb - ta); - return ps.lerp(pe, timePercent); - } else { - Quaternion qa = new Quaternion(points.s.p().evaluate(time).div(5)); - Quaternion qb = new Quaternion(points.e.p().evaluate(time).div(5)); - return slerp(qa, qb, timePercent).toEuler().mul(5); + Vec start = post(transform, times.get(i), time); // value leaving keyframe i + if ("step".equals(a.lerp())) return start; // hold the leaving value until next keyframe + + Vec end = pre(transform, times.get(i + 1), time); // value approaching keyframe i+1 + if (isSmooth(a.lerp()) || isSmooth(b.lerp())) { // catmullrom or bezier -> smooth spline + Vec before = post(transform, times.get(Math.max(0, i - 1)), time); + Vec after = pre(transform, times.get(Math.min(n - 1, i + 2)), time); + return catmullRom(before, start, end, after, alpha); } + return start.lerp(end, alpha); // linear } - static Point interpolateTranslation(double time, LinkedHashMap transform, double animationTime) { - StartEnd points = getStartEnd(time, transform, animationTime); - if (points == null) return Vec.ZERO; - - return getPoint(time, points); + private static boolean isSmooth(String lerp) { + // bezier is approximated as a Catmull-Rom smooth spline (its custom handles aren't exported) + return "catmullrom".equals(lerp) || "bezier".equals(lerp); } - public static Point interpolateScale(double time, LinkedHashMap transform, double animationTime) { - StartEnd points = getStartEnd(time, transform, animationTime); - if (points == null) return Vec.ONE; - - return getPoint(time, points); + // value approaching a keyframe (its primary / "pre" data point) + private static Vec pre(LinkedHashMap transform, double key, double time) { + return transform.get(key).p().evaluate(time).asVec(); } - private static Point getPoint(double time, StartEnd points) { - double timeDiff = points.et - points.st; - - if (timeDiff == 0) return points.s.p().evaluate(time); - double timePercent = (time - points.st) / timeDiff; - - Vec ps = points.s.p().evaluate(time).asVec(); - Vec pe = points.e.p().evaluate(time).asVec(); + // value leaving a keyframe (its second "post" data point on a discontinuity, else same as pre) + private static Vec post(LinkedHashMap transform, double key, double time) { + return transform.get(key).post().evaluate(time).asVec(); + } - return ps.lerp(pe, timePercent); + /** Uniform Catmull-Rom through p1..p2 with neighbours p0,p3 (matches Blockbench / bbrender). */ + static Vec catmullRom(Vec p0, Vec p1, Vec p2, Vec p3, double t) { + return new Vec(cr(p0.x(), p1.x(), p2.x(), p3.x(), t), + cr(p0.y(), p1.y(), p2.y(), p3.y(), t), + cr(p0.z(), p1.z(), p2.z(), p3.z(), t)); } - record StartEnd(BoneAnimationImpl.PointInterpolation s, BoneAnimationImpl.PointInterpolation e, double st, - double et) { + private static double cr(double p0, double p1, double p2, double p3, double t) { + double t2 = t * t, t3 = t2 * t; + return 0.5 * ((2 * p1) + + (-p0 + p2) * t + + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + + (-p0 + 3 * p1 - 3 * p2 + p3) * t3); } } diff --git a/src/main/java/net/worldseed/multipart/animations/ModelAnimation.java b/src/main/java/net/worldseed/multipart/animations/ModelAnimation.java index a5a7b5a..fa3e16e 100644 --- a/src/main/java/net/worldseed/multipart/animations/ModelAnimation.java +++ b/src/main/java/net/worldseed/multipart/animations/ModelAnimation.java @@ -22,4 +22,32 @@ public interface ModelAnimation { void tick(); + /** Current playback position in ticks while playing, or -1 if not playing. Used to time animation effects. */ + default int currentTick() { + return -1; + } + + /** Whether this animation loops (Blockbench loop mode). Non-looping animations hold their last frame. */ + default boolean loops() { + return true; + } + + /** Current blend weight in [0,1]. */ + default double weight() { + return 1.0; + } + + /** Set the blend weight immediately. */ + default void setWeight(double weight) { + } + + /** Ramp the blend weight toward {@code target} over {@code ticks} ticks (0 = instant). */ + default void blendTo(double target, int ticks) { + } + + /** True once this animation has finished blending out (weight and target both ~0) — safe to stop. */ + default boolean fadedOut() { + return false; + } + } diff --git a/src/main/java/net/worldseed/multipart/animations/ModelAnimationClassic.java b/src/main/java/net/worldseed/multipart/animations/ModelAnimationClassic.java index 44b44ce..c161d79 100644 --- a/src/main/java/net/worldseed/multipart/animations/ModelAnimationClassic.java +++ b/src/main/java/net/worldseed/multipart/animations/ModelAnimationClassic.java @@ -11,14 +11,21 @@ public class ModelAnimationClassic implements ModelAnimation { private AnimationHandler.AnimationDirection direction; private final Set boneAnimations; private final Set animatedBones; + private final boolean looping; - public ModelAnimationClassic(String name, int animationTime, int priority, HashSet animationSet, HashSet animatedBones) { + public ModelAnimationClassic(String name, int animationTime, int priority, HashSet animationSet, HashSet animatedBones, boolean looping) { this.direction = AnimationHandler.AnimationDirection.PAUSE; this.animationTime = animationTime; this.boneAnimations = animationSet; this.animatedBones = animatedBones; this.name = name; this.priority = priority; + this.looping = looping; + } + + @Override + public boolean loops() { + return looping; } @Override @@ -47,9 +54,40 @@ public void setDirection(AnimationHandler.AnimationDirection direction) { boneAnimations.forEach(a -> a.setDirection(direction)); } + private int effectTick = -1; // standalone playback clock so effects-only animations (no bones) still fire + private double weight = 1.0; + private double targetWeight = 1.0; + private double weightStep = 0.0; + + @Override + public double weight() { + return weight; + } + + @Override + public void setWeight(double w) { + this.weight = w; + this.targetWeight = w; + this.weightStep = 0; + boneAnimations.forEach(b -> b.setWeight(w)); + } + + @Override + public void blendTo(double target, int ticks) { + this.targetWeight = target; + this.weightStep = ticks <= 0 ? (target - weight) : (target - weight) / ticks; + if (ticks <= 0) setWeight(target); + } + + @Override + public boolean fadedOut() { + return weight <= 0.001 && targetWeight <= 0.001; + } + @Override public void stop() { boneAnimations.forEach(BoneAnimation::stop); + this.effectTick = -1; } @Override @@ -71,11 +109,34 @@ public void play(boolean resume) { } } boneAnimations.forEach(BoneAnimation::play); + this.effectTick = 0; } @Override public void tick() { boneAnimations.forEach(BoneAnimation::tick); + if (weight != targetWeight) { + weight += weightStep; + if ((weightStep >= 0 && weight >= targetWeight) || (weightStep < 0 && weight <= targetWeight)) weight = targetWeight; + boneAnimations.forEach(b -> b.setWeight(weight)); + } + if (effectTick >= 0 && direction != AnimationHandler.AnimationDirection.PAUSE) { + if (direction == AnimationHandler.AnimationDirection.FORWARD) { + effectTick++; + if (effectTick > animationTime && animationTime != 0) effectTick = looping ? 0 : animationTime; + } else if (direction == AnimationHandler.AnimationDirection.BACKWARD) { + effectTick--; + if (effectTick < 0 && animationTime != 0) effectTick = looping ? animationTime : 0; + } + } + } + + @Override + public int currentTick() { + for (BoneAnimation boneAnimation : boneAnimations) { + if (boneAnimation.isPlaying()) return boneAnimation.getTick(); + } + return effectTick; // -1 when stopped; the standalone clock for effects-only animations } public Set getAnimatedBones() { diff --git a/src/main/java/net/worldseed/multipart/model_bones/ModelBoneImpl.java b/src/main/java/net/worldseed/multipart/model_bones/ModelBoneImpl.java index 7913977..7b9dd62 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/ModelBoneImpl.java +++ b/src/main/java/net/worldseed/multipart/model_bones/ModelBoneImpl.java @@ -31,6 +31,21 @@ public abstract class ModelBoneImpl implements ModelBone { protected BoneEntity stand; private ModelBone parent; + // Per-draw memoization of the propagated (local) rotation/scale. These are pure functions of the + // current animation tick, but were recomputed once per descendant while walking parent chains + // (O(N*depth) per tick). A monotonic draw-frame counter (bumped once per model draw) means a cache + // entry can never be falsely reused across ticks; every transform reader runs inside a draw. + private static volatile long globalDrawFrame = 0; + public static void beginDrawFrame() { globalDrawFrame++; } + private long propFrame = -1; + private Point cachedPropagatedRotation; + private Point cachedPropagatedScale; + // per-draw memoized WORLD rotation/scale so draw() doesn't re-walk the parent chain (equivalent to + // calculateFinalAngle/calculateFinalScale, computed once per bone per draw -> O(N) instead of O(N*depth)). + private long worldFrame = -1; + private Quaternion cachedWorldRotation; + private Point cachedWorldScale; + public ModelBoneImpl(Point pivot, String name, Point rotation, GenericModel model, float scale) { this.name = name; this.rotation = rotation; @@ -101,7 +116,7 @@ public Point applyTransform(Point p) { if (currentAnimation != null && currentAnimation.isPlaying()) { if (currentAnimation.getType() == AnimationType.TRANSLATION) { var calculatedTransform = currentAnimation.getTransform(); - endPos = endPos.add(calculatedTransform); + endPos = endPos.add(calculatedTransform.mul(currentAnimation.weight())); // blend weight } } } @@ -113,35 +128,37 @@ public Point applyTransform(Point p) { return endPos; } - public Point getPropagatedRotation() { - Point netTransform = Vec.ZERO; - + /** Compute propagated rotation AND scale once per draw frame (single pass over allAnimations). */ + private void computePropagated() { + if (this.propFrame == globalDrawFrame) return; + Point rot = Vec.ZERO; + Point scale = Vec.ONE; for (BoneAnimation currentAnimation : this.allAnimations) { if (currentAnimation != null && currentAnimation.isPlaying()) { - if (currentAnimation.getType() == AnimationType.ROTATION) { - Point calculatedTransform = currentAnimation.getTransform(); - netTransform = netTransform.add(calculatedTransform); + AnimationType type = currentAnimation.getType(); + double w = currentAnimation.weight(); // blend weight + if (type == AnimationType.ROTATION) { + rot = rot.add(currentAnimation.getTransform().mul(w)); + } else if (type == AnimationType.SCALE) { + Point t = currentAnimation.getTransform(); + scale = scale.mul(Vec.ONE.add(t.sub(Vec.ONE).mul(w))); // lerp(ONE, t, w) } } } + this.cachedPropagatedRotation = this.rotation.add(rot); + this.cachedPropagatedScale = scale; + this.propFrame = globalDrawFrame; + } - return this.rotation.add(netTransform); + public Point getPropagatedRotation() { + computePropagated(); + return this.cachedPropagatedRotation; } @Override public Point getPropagatedScale() { - Point netTransform = Vec.ONE; - - for (BoneAnimation currentAnimation : this.allAnimations) { - if (currentAnimation != null && currentAnimation.isPlaying()) { - if (currentAnimation.getType() == AnimationType.SCALE) { - Point calculatedTransform = currentAnimation.getTransform(); - netTransform = netTransform.mul(calculatedTransform); - } - } - } - - return netTransform; + computePropagated(); + return this.cachedPropagatedScale; } @Override @@ -163,6 +180,31 @@ public Quaternion calculateFinalAngle(Quaternion q) { return q; } + private void computeWorld() { + Quaternion localRotation = new Quaternion(getPropagatedRotation()); + Point localScale = getPropagatedScale(); + if (this.parent instanceof ModelBoneImpl p) { + this.cachedWorldRotation = p.worldRotation().multiply(localRotation); + this.cachedWorldScale = p.worldScale().mul(localScale); + } else { + this.cachedWorldRotation = localRotation; + this.cachedWorldScale = localScale; + } + this.worldFrame = globalDrawFrame; + } + + /** Memoized world rotation — equals {@code calculateFinalAngle(new Quaternion(getPropagatedRotation()))}. */ + public Quaternion worldRotation() { + if (this.worldFrame != globalDrawFrame) computeWorld(); + return this.cachedWorldRotation; + } + + /** Memoized world scale — equals {@code calculateFinalScale(getPropagatedScale())}. */ + public Point worldScale() { + if (this.worldFrame != globalDrawFrame) computeWorld(); + return this.cachedWorldScale; + } + public void addAnimation(BoneAnimation animation) { this.allAnimations.add(animation); } diff --git a/src/main/java/net/worldseed/multipart/model_bones/display_entity/ModelBoneHeadDisplay.java b/src/main/java/net/worldseed/multipart/model_bones/display_entity/ModelBoneHeadDisplay.java index 174eafa..205d777 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/display_entity/ModelBoneHeadDisplay.java +++ b/src/main/java/net/worldseed/multipart/model_bones/display_entity/ModelBoneHeadDisplay.java @@ -22,7 +22,7 @@ public Point getPropagatedRotation() { if (currentAnimation != null && currentAnimation.isPlaying()) { if (currentAnimation.getType() == ModelLoader.AnimationType.ROTATION) { Point calculatedTransform = currentAnimation.getTransform(); - netTransform = netTransform.add(calculatedTransform); + netTransform = netTransform.add(calculatedTransform.mul(currentAnimation.weight())); // blend weight } } } diff --git a/src/main/java/net/worldseed/resourcepack/multipart/generator/AnimationGenerator.java b/src/main/java/net/worldseed/resourcepack/multipart/generator/AnimationGenerator.java index 72c1923..dafea8e 100644 --- a/src/main/java/net/worldseed/resourcepack/multipart/generator/AnimationGenerator.java +++ b/src/main/java/net/worldseed/resourcepack/multipart/generator/AnimationGenerator.java @@ -14,10 +14,13 @@ public static JsonObject generate(JsonArray animationRaw) { for (int i = 0; i < animationRaw.size(); i++) { JsonObject animation = animationRaw.getJsonObject(i); - String name = animation.getString("name"); - double length = animation.getJsonNumber("length").doubleValue(); + String name = animation.getString("name", null); + if (name == null) continue; + JsonNumber lengthNumber = animation.getJsonNumber("length"); + double length = lengthNumber == null ? 0 : lengthNumber.doubleValue(); JsonObjectBuilder bones = Json.createObjectBuilder(); + JsonArrayBuilder effects = Json.createArrayBuilder(); var foundAnimations = animation.getJsonObject("animators"); if (foundAnimations == null) continue; @@ -29,25 +32,52 @@ public static JsonObject generate(JsonArray animationRaw) { String type = animator.getString("type", "bone"); + if (type.equals("effect")) { + JsonArray effectKeyframes = animator.getJsonArray("keyframes"); + if (effectKeyframes != null) { + for (int k = 0; k < effectKeyframes.size(); k++) { + JsonObject keyframe = effectKeyframes.getJsonObject(k); + String channel = keyframe.getString("channel", ""); + if (!channel.equals("sound") && !channel.equals("particle") && !channel.equals("timeline")) continue; + JsonNumber effectTime = keyframe.getJsonNumber("time"); + if (effectTime == null) continue; + JsonArray dataPoints = keyframe.getJsonArray("data_points"); + JsonObject data = (dataPoints != null && !dataPoints.isEmpty()) ? dataPoints.getJsonObject(0) : JsonValue.EMPTY_JSON_OBJECT; + JsonObjectBuilder effect = Json.createObjectBuilder() + .add("time", effectTime.doubleValue()) + .add("channel", channel); + if (data.containsKey("effect")) effect.add("effect", data.getString("effect", "")); + if (data.containsKey("locator")) effect.add("locator", data.getString("locator", "")); + if (data.containsKey("script")) effect.add("script", data.getString("script", "")); + effects.add(effect.build()); + } + } + continue; + } + if (!type.equals("bone")) continue; - String boneName = animator.getString("name"); + String boneName = animator.getString("name", null); + if (boneName == null) continue; // malformed animator without a bone name List> rotation = new ArrayList<>(); List> position = new ArrayList<>(); List> scale = new ArrayList<>(); JsonArray keyframes = animator.getJsonArray("keyframes"); + if (keyframes == null) continue; for (int k = 0; k < keyframes.size(); k++) { JsonObject keyframe = keyframes.getJsonObject(k); - String channel = keyframe.getString("channel"); - - double time = keyframe.getJsonNumber("time").doubleValue(); + String channel = keyframe.getString("channel", null); + JsonNumber timeNumber = keyframe.getJsonNumber("time"); + JsonArray dataPoints = keyframe.getJsonArray("data_points"); + if (channel == null || timeNumber == null || dataPoints == null) continue; // skip malformed keyframe - String interpolation = keyframe.getString("interpolation"); + double time = timeNumber.doubleValue(); + String interpolation = keyframe.getString("interpolation", "linear"); JsonObject built = Json.createObjectBuilder() - .add("post", keyframe.getJsonArray("data_points")) + .add("post", dataPoints) .add("lerp_mode", interpolation) .build(); @@ -88,9 +118,10 @@ public static JsonObject generate(JsonArray animationRaw) { } JsonObject built = Json.createObjectBuilder() - .add("loop", animation.getString("loop").equals("loop")) + .add("loop", animation.getString("loop", "once").equals("loop")) .add("animation_length", length) .add("bones", bones) + .add("effects", effects) .build(); animations.add(name, built); diff --git a/src/test/java/net/worldseed/multipart/animations/AnimationEffectsTest.java b/src/test/java/net/worldseed/multipart/animations/AnimationEffectsTest.java new file mode 100644 index 0000000..76678f3 --- /dev/null +++ b/src/test/java/net/worldseed/multipart/animations/AnimationEffectsTest.java @@ -0,0 +1,65 @@ +package net.worldseed.multipart.animations; + +import net.worldseed.resourcepack.multipart.generator.AnimationGenerator; +import org.junit.jupiter.api.Test; + +import javax.json.Json; +import javax.json.JsonArray; +import javax.json.JsonObject; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The Blockbench "effects" animator (sound/particle/timeline) must survive AnimationGenerator into the + * generated animation JSON so the runtime can fire it. (Bone animators used to be the only ones kept.) */ +class AnimationEffectsTest { + + private static JsonObject dataPoint(String key, String value) { + return Json.createObjectBuilder().add(key, value).build(); + } + + private JsonObject generateFrom(JsonArray animators) { + JsonArray animations = Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("name", "test") + .add("length", 2.0) + .add("loop", "loop") + .add("animators", animators.isEmpty() + ? Json.createObjectBuilder() + : Json.createObjectBuilder().add("fx", animators.getJsonObject(0)))) + .build(); + return AnimationGenerator.generate(animations); + } + + @Test + void soundAndParticleEffectsSurviveGeneration() { + JsonObject effectAnimator = Json.createObjectBuilder() + .add("name", "effects") + .add("type", "effect") + .add("keyframes", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("channel", "sound").add("time", 0.5) + .add("data_points", Json.createArrayBuilder().add(dataPoint("effect", "minecraft:block.anvil.land")))) + .add(Json.createObjectBuilder() + .add("channel", "particle").add("time", 1.0) + .add("data_points", Json.createArrayBuilder() + .add(Json.createObjectBuilder().add("effect", "minecraft:flame").add("locator", "muzzle"))))) + .build(); + + JsonObject out = generateFrom(Json.createArrayBuilder().add(effectAnimator).build()); + JsonArray effects = out.getJsonObject("test").getJsonArray("effects"); + + assertEquals(2, effects.size(), "both effect keyframes should be emitted"); + assertEquals("sound", effects.getJsonObject(0).getString("channel")); + assertEquals("minecraft:block.anvil.land", effects.getJsonObject(0).getString("effect")); + assertEquals(0.5, effects.getJsonObject(0).getJsonNumber("time").doubleValue()); + assertEquals("particle", effects.getJsonObject(1).getString("channel")); + assertEquals("muzzle", effects.getJsonObject(1).getString("locator")); + } + + @Test + void modelWithNoEffectsGetsEmptyEffectsArray() { + JsonObject out = generateFrom(Json.createArrayBuilder().build()); + assertTrue(out.getJsonObject("test").getJsonArray("effects").isEmpty()); + } +} diff --git a/src/test/java/net/worldseed/multipart/animations/InterpolatorTest.java b/src/test/java/net/worldseed/multipart/animations/InterpolatorTest.java new file mode 100644 index 0000000..40787fa --- /dev/null +++ b/src/test/java/net/worldseed/multipart/animations/InterpolatorTest.java @@ -0,0 +1,76 @@ +package net.worldseed.multipart.animations; + +import net.worldseed.multipart.animations.BoneAnimationImpl.PointInterpolation; +import net.worldseed.multipart.mql.MQLPoint; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** Blockbench-faithful interpolation behaviour (validated end-to-end against the bbrender oracle; + * these unit assertions lock the per-mode math in without needing the oracle at CI time). */ +class InterpolatorTest { + + private static PointInterpolation kf(double v, String lerp) { + return new PointInterpolation(new MQLPoint(v, 0, 0), lerp); + } + + private static LinkedHashMap map(Object... kv) { + var m = new LinkedHashMap(); + for (int i = 0; i < kv.length; i += 2) m.put((Double) kv[i], (PointInterpolation) kv[i + 1]); + return m; + } + + @Test + void linearInterpolatesBetweenKeyframes() { + var t = map(0.0, kf(0, "linear"), 1.0, kf(10, "linear")); + assertEquals(5.0, Interpolator.interpolateTranslation(0.5, t, 1.0).x(), 1e-9); + } + + @Test + void stepHoldsStartUntilNextKeyframe() { + var t = map(0.0, kf(0, "step"), 1.0, kf(10, "step")); + assertEquals(0.0, Interpolator.interpolateTranslation(0.5, t, 1.0).x(), 1e-9, "step must hold, not smooth"); + assertEquals(10.0, Interpolator.interpolateTranslation(1.0, t, 1.0).x(), 1e-9); + } + + @Test + void catmullRomMatchesUniformSpline() { + // 4 keyframes 0,0,10,10; segment [1,2] at alpha 0.5 -> uniform Catmull-Rom = 5.0 + var t = map(0.0, kf(0, "catmullrom"), 1.0, kf(0, "catmullrom"), + 2.0, kf(10, "catmullrom"), 3.0, kf(10, "catmullrom")); + assertEquals(5.0, Interpolator.interpolateTranslation(1.5, t, 3.0).x(), 1e-9); + } + + @Test + void translationAndScaleRespectInterpolationMode() { + // previously translation/scale ignored lerp_mode (always linear); step must now hold for them too + var t = map(0.0, kf(2, "step"), 1.0, kf(8, "step")); + assertEquals(2.0, Interpolator.interpolateScale(0.5, t, 1.0).x(), 1e-9); + } + + @Test + void rotationInterpolatesEulerComponentsNotQuaternionSlerp() { + // a full-turn spin 0 -> -360 must read -180 at the midpoint (component lerp), not collapse via slerp + var t = map(0.0, kf(0, "linear"), 1.0, kf(-360, "linear")); + assertEquals(-180.0, Interpolator.interpolateRotation(0.5, t, 1.0).x(), 1e-9); + } + + @Test + void bezierInterpolatesAsSmoothSpline() { + // bezier is approximated as Catmull-Rom, so it matches the smooth-spline midpoint (5.0), not linear + var t = map(0.0, kf(0, "bezier"), 1.0, kf(0, "bezier"), 2.0, kf(10, "bezier"), 3.0, kf(10, "bezier")); + assertEquals(5.0, Interpolator.interpolateTranslation(1.5, t, 3.0).x(), 1e-9); + } + + @Test + void discontinuousKeyframeUsesLeavingValue() { + var t = new LinkedHashMap(); + // keyframe at t=0 jumps: approached as 0, leaves as 5; then linear to 10 at t=1 + t.put(0.0, new BoneAnimationImpl.PointInterpolation(new MQLPoint(0, 0, 0), new MQLPoint(5, 0, 0), "linear")); + t.put(1.0, new BoneAnimationImpl.PointInterpolation(new MQLPoint(10, 0, 0), "linear")); + // segment leaves keyframe 0 at 5, approaches keyframe 1 at 10 -> midpoint 7.5 + assertEquals(7.5, Interpolator.interpolateTranslation(0.5, t, 1.0).x(), 1e-9); + } +} From 3f88e52803c2ba86621e3d1c7f110b695cb84418 Mon Sep 17 00:00:00 2001 From: Alexander Parent Date: Mon, 6 Jul 2026 17:21:48 -0400 Subject: [PATCH 4/6] core: ModelEntity, normal hit events, bone visibility, locators - Add ModelEntity: extend it instead of hand-wiring an invisible carrier entity. It owns the GenericModel and auto-syncs viewers + position + lifecycle, and is the hit "owner". - Replace custom ModelDamageEvent/ModelInteractEvent: hits on a model hitbox now surface as NORMAL Minestom EntityDamageEvent / PlayerEntityInteractEvent on the owner entity (GenericModel#getOwner). Removes both custom event classes. - Bone visibility: GenericModel#setBoneVisible / ModelBone#setVisible show/hide a bone at runtime. - Locators: GenericModel#getLocator returns a locator's world position (locators are now emitted as position-only bones instead of being dropped). - Minestom 26.1.2 API fixes (getViewers wildcard, sendPacketsToViewers, LazyPacket removal) + per-bone metadata dirty-check to stop re-sending unchanged transforms. - setNametag(bone, Component) creates a modern TextDisplay nametag. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FsZs24bSHE9fCDzFbwyhuH --- .../net/worldseed/gestures/EmotePlayer.java | 12 +- .../net/worldseed/multipart/GenericModel.java | 54 +++++++++ .../worldseed/multipart/GenericModelImpl.java | 48 +++++++- .../net/worldseed/multipart/ModelEngine.java | 19 ++-- .../net/worldseed/multipart/ModelEntity.java | 79 +++++++++++++ .../multipart/events/ModelDamageEvent.java | 104 ------------------ .../multipart/events/ModelInteractEvent.java | 46 -------- .../multipart/model_bones/BoneEntity.java | 5 +- .../multipart/model_bones/ModelBone.java | 7 ++ .../display_entity/ModelBonePartDisplay.java | 55 +++++++-- 10 files changed, 247 insertions(+), 182 deletions(-) create mode 100644 src/main/java/net/worldseed/multipart/ModelEntity.java delete mode 100644 src/main/java/net/worldseed/multipart/events/ModelDamageEvent.java delete mode 100644 src/main/java/net/worldseed/multipart/events/ModelInteractEvent.java diff --git a/src/main/java/net/worldseed/gestures/EmotePlayer.java b/src/main/java/net/worldseed/gestures/EmotePlayer.java index 91420e6..b71d905 100644 --- a/src/main/java/net/worldseed/gestures/EmotePlayer.java +++ b/src/main/java/net/worldseed/gestures/EmotePlayer.java @@ -10,8 +10,6 @@ import net.minestom.server.instance.Instance; import net.worldseed.multipart.animations.AnimationHandler; import net.worldseed.multipart.animations.AnimationHandlerImpl; -import net.worldseed.multipart.events.ModelDamageEvent; -import net.worldseed.multipart.events.ModelInteractEvent; import org.jetbrains.annotations.NotNull; import java.util.Map; @@ -47,14 +45,8 @@ protected void loadDefaultAnimations() { } }; - this.eventNode().addListener(EntityDamageEvent.class, (event) -> { - event.setCancelled(true); - ModelDamageEvent modelDamageEvent = new ModelDamageEvent(model, event); - EventDispatcher.call(modelDamageEvent); - }).addListener(PlayerEntityInteractEvent.class, (event) -> { - ModelInteractEvent modelInteractEvent = new ModelInteractEvent(model, event); - EventDispatcher.call(modelInteractEvent); - }); + // Hits on the emote model surface as normal Minestom events; keep the emote itself invulnerable. + this.eventNode().addListener(EntityDamageEvent.class, event -> event.setCancelled(true)); this.model.draw(); this.model.draw(); diff --git a/src/main/java/net/worldseed/multipart/GenericModel.java b/src/main/java/net/worldseed/multipart/GenericModel.java index 4ee2142..35b947d 100644 --- a/src/main/java/net/worldseed/multipart/GenericModel.java +++ b/src/main/java/net/worldseed/multipart/GenericModel.java @@ -94,6 +94,15 @@ public interface GenericModel extends Viewable, EventHandler<@NonNull ModelEvent */ void setState(String state); + /** + * Spawn the model's bones into the given instance at the given position. Usually called for you by + * {@link ModelEntity}; call it directly only when driving a model without a {@link ModelEntity}. + * + * @param instance the instance to spawn in + * @param position the position to spawn at + */ + void init(@Nullable Instance instance, @NotNull Pos position); + /** * Destroy the model */ @@ -113,9 +122,27 @@ public interface GenericModel extends Viewable, EventHandler<@NonNull ModelEvent */ Point getVFX(String name); + /** + * Get a Blockbench locator's current world position, or null if there's no such locator/bone. Useful for + * anchoring particles, projectiles or attached entities at authored points. + * + * @param name the locator (or bone) name + * @return the world position, or null + */ + Point getLocator(String name); + @ApiStatus.Internal ModelBone getPart(String boneName); + /** + * Show or hide a bone at runtime (e.g. damage states, equipment, phase changes). A hidden bone's + * display entity is removed for all current and future viewers until shown again. + * + * @param boneName the bone to toggle + * @param visible true to show, false to hide + */ + void setBoneVisible(String boneName, boolean visible); + @ApiStatus.Internal void draw(); @@ -131,6 +158,22 @@ public interface GenericModel extends Viewable, EventHandler<@NonNull ModelEvent Instance getInstance(); + /** + * The entity that owns/drives this model — the source of its position and viewers, and the entity + * that receives hits landed on the model's hitboxes (as normal Minestom events). Set automatically + * when the model is bound to a {@link ModelEntity}; null if the model is driven manually. + * + * @return the owning entity, or null + */ + @Nullable Entity getOwner(); + + /** + * Set the owning entity (see {@link #getOwner()}). + * + * @param owner the owning entity, or null to unbind + */ + void setOwner(@Nullable Entity owner); + Point getOffset(String bone); Point getDiff(String bone); @@ -157,6 +200,17 @@ public interface GenericModel extends Viewable, EventHandler<@NonNull ModelEvent void bindNametag(String name, Entity nametag); + /** + * Create a floating {@link net.minestom.server.entity.EntityType#TEXT_DISPLAY} showing {@code text} and + * bind it to the given nametag bone — the modern replacement for attaching an armor stand. Returns the + * created entity so you can tweak its meta (background, billboard, line width…). + * + * @param name the nametag bone name + * @param text the text to show + * @return the created text-display entity + */ + Entity setNametag(String name, net.kyori.adventure.text.Component text); + void unbindNametag(String name); @Nullable Entity getNametag(String name); diff --git a/src/main/java/net/worldseed/multipart/GenericModelImpl.java b/src/main/java/net/worldseed/multipart/GenericModelImpl.java index e349e73..d10a972 100644 --- a/src/main/java/net/worldseed/multipart/GenericModelImpl.java +++ b/src/main/java/net/worldseed/multipart/GenericModelImpl.java @@ -185,6 +185,11 @@ protected void loadBones(JsonObject loadedModel, float scale) { Point boneRotation = ModelEngine.getPos(bone.getAsJsonObject().get("rotation")).orElse(Pos.ZERO).mul(-1, -1, 1); Point pivotPos = ModelEngine.getPos(pivot).orElse(Pos.ZERO).mul(-1, 1, 1); + if (bone.getAsJsonObject().has("locator")) { // position-only locator bone (see getLocator) + parts.put(name, new ModelBoneVFX(pivotPos, name, boneRotation, this, scale)); + continue; + } + boolean found = false; for (Map.Entry, Function> entry : this.boneSuppliers.entrySet()) { var predicate = entry.getKey(); @@ -231,6 +236,18 @@ public Instance getInstance() { return instance; } + private Entity owner; + + @Override + public Entity getOwner() { + return owner; + } + + @Override + public void setOwner(Entity owner) { + this.owner = owner; + } + public void setState(String state) { for (ModelBoneImpl part : viewableBones) { part.setState(state); @@ -242,6 +259,7 @@ public ModelBone getPart(String boneName) { } public void draw() { + net.worldseed.multipart.model_bones.ModelBoneImpl.beginDrawFrame(); for (ModelBone modelBonePart : this.parts.values()) { if (modelBonePart.getParent() == null) modelBonePart.draw(); @@ -264,6 +282,18 @@ public Point getVFX(String name) { return found.getPosition(); } + @Override + public void setBoneVisible(String boneName, boolean visible) { + ModelBone bone = this.parts.get(boneName); + if (bone != null) bone.setVisible(visible); + } + + @Override + public Point getLocator(String name) { + ModelBone found = this.parts.get(name); + return found == null ? null : found.getPosition(); + } + @Override public void setHeadRotation(String name, double rotation) { ModelBone found = this.parts.get(name); @@ -308,7 +338,7 @@ public void sendPacketToViewers(@NotNull SendablePacket packet) { } @Override - public void sendPacketsToViewers(@NotNull Collection packets) { + public void sendPacketsToViewers(@NotNull Collection packets) { for (Player viewer : this.viewers) { for (SendablePacket packet : packets) { viewer.sendPacket(packet); @@ -370,7 +400,9 @@ public void addPartsAsPassengers(Player player) { @Override public @NotNull Set<@NotNull Player> getViewers() { - return Set.copyOf(this.viewers); + // live unmodifiable view (viewers is a concurrent key-set, safe to iterate) instead of a fresh + // Set.copyOf on every call — the per-tick metadata flush of every bone reads this. + return java.util.Collections.unmodifiableSet(this.viewers); } @Override @@ -503,6 +535,18 @@ public void bindNametag(String name, Entity nametag) { if (this.parts.get(name) instanceof ModelBoneNametag nametagBone) nametagBone.bind(nametag); } + @Override + public Entity setNametag(String name, net.kyori.adventure.text.Component text) { + Entity display = new Entity(net.minestom.server.entity.EntityType.TEXT_DISPLAY); + var meta = (net.minestom.server.entity.metadata.display.TextDisplayMeta) display.getEntityMeta(); + meta.setText(text); + meta.setBillboardRenderConstraints(net.minestom.server.entity.metadata.display.AbstractDisplayMeta.BillboardConstraints.CENTER); + display.setNoGravity(true); + if (getInstance() != null) display.setInstance(getInstance(), getPosition()); + bindNametag(name, display); + return display; + } + @Override public void unbindNametag(String name) { if (this.parts.get(name) instanceof ModelBoneNametag nametagBone) nametagBone.unbind(); diff --git a/src/main/java/net/worldseed/multipart/ModelEngine.java b/src/main/java/net/worldseed/multipart/ModelEngine.java index 2748ae5..a075c93 100644 --- a/src/main/java/net/worldseed/multipart/ModelEngine.java +++ b/src/main/java/net/worldseed/multipart/ModelEngine.java @@ -7,6 +7,7 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; import net.minestom.server.entity.Entity; +import net.minestom.server.entity.LivingEntity; import net.minestom.server.event.EventDispatcher; import net.minestom.server.event.EventListener; import net.minestom.server.event.entity.EntityDamageEvent; @@ -17,8 +18,6 @@ import net.minestom.server.item.component.CustomModelData; import net.minestom.server.network.packet.client.play.ClientInputPacket; import net.worldseed.multipart.events.ModelControlEvent; -import net.worldseed.multipart.events.ModelDamageEvent; -import net.worldseed.multipart.events.ModelInteractEvent; import net.worldseed.multipart.model_bones.BoneEntity; import net.worldseed.multipart.mql.MQLPoint; import org.jspecify.annotations.NonNull; @@ -45,17 +44,23 @@ public class ModelEngine { } } }); + // A hit on a model hitbox (an INTERACTION/BoneEntity) is surfaced as a NORMAL Minestom event on the + // model's owner entity (see GenericModel#getOwner / ModelEntity) — no custom WSEE events to hook. private static final EventListener<@NonNull PlayerEntityInteractEvent> playerInteractListener = EventListener.of(PlayerEntityInteractEvent.class, event -> { if (event.getTarget() instanceof BoneEntity bone) { - ModelInteractEvent modelInteractEvent = new ModelInteractEvent(bone.getModel(), event, bone); - EventDispatcher.call(modelInteractEvent); + Entity owner = bone.getModel().getOwner(); + if (owner != null && owner != event.getTarget()) { + EventDispatcher.call(new PlayerEntityInteractEvent(event.getPlayer(), owner, event.getHand(), event.getInteractPosition())); + } } }); private static final EventListener<@NonNull EntityDamageEvent> entityDamageListener = EventListener.of(EntityDamageEvent.class, event -> { if (event.getEntity() instanceof BoneEntity bone) { - event.setCancelled(true); - ModelDamageEvent modelDamageEvent = new ModelDamageEvent(bone.getModel(), event, bone); - MinecraftServer.getGlobalEventHandler().call(modelDamageEvent); + event.setCancelled(true); // the hitbox entity itself never takes damage + Entity owner = bone.getModel().getOwner(); + if (owner instanceof LivingEntity living && owner != bone) { + living.damage(event.getDamage()); // re-raises a normal EntityDamageEvent on the owner + } } }); private static Path modelPath; diff --git a/src/main/java/net/worldseed/multipart/ModelEntity.java b/src/main/java/net/worldseed/multipart/ModelEntity.java new file mode 100644 index 0000000..9b28dbf --- /dev/null +++ b/src/main/java/net/worldseed/multipart/ModelEntity.java @@ -0,0 +1,79 @@ +package net.worldseed.multipart; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.EntityCreature; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import org.jetbrains.annotations.NotNull; + +/** + * A real Minestom entity that owns and drives a {@link GenericModel}. Extend this instead of hand-wiring + * an invisible "carrier" entity to a model. + * + *

It removes the boilerplate that every WSEE mob used to repeat: + *

    + *
  • viewers — the model is shown to exactly the players who can see this entity (auto-synced),
  • + *
  • position — the model follows this entity every tick,
  • + *
  • lifecycle — the model is destroyed with this entity,
  • + *
  • hits — a hit on any of the model's hitboxes arrives as a normal Minestom + * {@link net.minestom.server.event.entity.EntityDamageEvent} / + * {@link net.minestom.server.event.player.PlayerEntityInteractEvent} on this entity + * (WSEE routes it via {@link GenericModel#getOwner()}); no custom events to hook.
  • + *
+ * + *

The carrier entity is made invisible by default so only the model is seen. Typical usage: + *

{@code
+ * public class GemGolem extends ModelEntity {
+ *     public GemGolem(Instance instance, Pos pos) {
+ *         super(EntityType.PUFFERFISH, new GemGolemModel(), instance, pos);
+ *     }
+ * }
+ * // then just: node.addListener(EntityDamageEvent.class, e -> { if (e.getEntity() instanceof GemGolem g) ... });
+ * }
+ */ +public class ModelEntity extends EntityCreature { + private final GenericModel model; + + public ModelEntity(@NotNull EntityType entityType, @NotNull GenericModel model, + @NotNull Instance instance, @NotNull Pos position) { + super(entityType); + this.model = model; + setInvisible(true); + model.setOwner(this); + model.init(instance, position); + setInstance(instance, position); + } + + /** The model this entity drives. */ + public @NotNull GenericModel getModel() { + return model; + } + + @Override + public void updateNewViewer(@NotNull Player player) { + super.updateNewViewer(player); + model.addViewer(player); + } + + @Override + public void updateOldViewer(@NotNull Player player) { + super.updateOldViewer(player); + model.removeViewer(player); + } + + @Override + public void tick(long time) { + super.tick(time); + if (isRemoved()) return; + Pos pos = getPosition(); + model.setPosition(pos); + model.setGlobalRotation(pos.yaw(), pos.pitch()); + } + + @Override + public void remove() { + model.destroy(); + super.remove(); + } +} diff --git a/src/main/java/net/worldseed/multipart/events/ModelDamageEvent.java b/src/main/java/net/worldseed/multipart/events/ModelDamageEvent.java deleted file mode 100644 index ea77527..0000000 --- a/src/main/java/net/worldseed/multipart/events/ModelDamageEvent.java +++ /dev/null @@ -1,104 +0,0 @@ -package net.worldseed.multipart.events; - -import net.minestom.server.entity.damage.Damage; -import net.minestom.server.event.entity.EntityDamageEvent; -import net.minestom.server.event.trait.CancellableEvent; -import net.minestom.server.sound.SoundEvent; -import net.worldseed.multipart.GenericModel; -import net.worldseed.multipart.model_bones.BoneEntity; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -public class ModelDamageEvent implements ModelEvent, CancellableEvent { - private final GenericModel model; - private final BoneEntity hitBone; - private final Damage damage; - private SoundEvent sound; - - private boolean animation = true; - - private boolean cancelled; - - public ModelDamageEvent(GenericModel model, EntityDamageEvent event) { - this(model, event, null); - } - - public ModelDamageEvent(GenericModel model, EntityDamageEvent event, @Nullable BoneEntity hitBone) { - this.model = model; - this.hitBone = hitBone; - this.damage = event.getDamage(); - this.sound = event.getSound(); - this.animation = event.shouldAnimate(); - } - - /** - * Gets the damage. - * - * @return the damage - */ - @NotNull - public Damage getDamage() { - return damage; - } - - /** - * Gets the damage sound. - * - * @return the damage sound - */ - @Nullable - public SoundEvent getSound() { - return sound; - } - - /** - * Changes the damage sound. - * - * @param sound the new damage sound - */ - public void setSound(@Nullable SoundEvent sound) { - this.sound = sound; - } - - /** - * Gets whether the damage animation should be played. - * - * @return true if the animation should be played - */ - public boolean shouldAnimate() { - return animation; - } - - /** - * Sets whether the damage animation should be played. - * - * @param animation whether the animation should be played or not - */ - public void setAnimation(boolean animation) { - this.animation = animation; - } - - /** - * Gets the hitbox bone that has been hit. - * - * @return the hitbox bone that has been hit, or null if it was an EmotePlayer - */ - public @Nullable BoneEntity getBone() { - return hitBone; - } - - @Override - public boolean isCancelled() { - return cancelled; - } - - @Override - public void setCancelled(boolean cancel) { - this.cancelled = cancel; - } - - @Override - public GenericModel model() { - return model; - } -} diff --git a/src/main/java/net/worldseed/multipart/events/ModelInteractEvent.java b/src/main/java/net/worldseed/multipart/events/ModelInteractEvent.java deleted file mode 100644 index 612dd5d..0000000 --- a/src/main/java/net/worldseed/multipart/events/ModelInteractEvent.java +++ /dev/null @@ -1,46 +0,0 @@ -package net.worldseed.multipart.events; - -import net.minestom.server.entity.Player; -import net.minestom.server.entity.PlayerHand; -import net.minestom.server.event.player.PlayerEntityInteractEvent; -import net.worldseed.multipart.model_bones.BoneEntity; -import net.worldseed.gestures.EmoteModel; -import net.worldseed.multipart.GenericModel; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -public class ModelInteractEvent implements ModelEvent { - private final GenericModel model; - private final Player interactor; - private final BoneEntity interactedBone; - private final PlayerHand hand; - - public ModelInteractEvent(@NotNull EmoteModel model, PlayerEntityInteractEvent event) { - this(model, event, null); - } - - public ModelInteractEvent(@NotNull GenericModel model, PlayerEntityInteractEvent event, @Nullable BoneEntity interactedBone) { - this.model = model; - this.hand = event.getHand(); - this.interactor = event.getPlayer(); - this.interactedBone = interactedBone; - } - - @Override - public @NotNull GenericModel model() { - return model; - } - - public @NotNull PlayerHand getHand() { - return hand; - } - - public @NotNull Player getInteracted() { // This should probably be getInteractor() or getPlayer() but I left this untouched so code doesn't break - return interactor; - } - - public @Nullable BoneEntity getBone() { - return interactedBone; - } -} - diff --git a/src/main/java/net/worldseed/multipart/model_bones/BoneEntity.java b/src/main/java/net/worldseed/multipart/model_bones/BoneEntity.java index 73ef3dc..47e929e 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/BoneEntity.java +++ b/src/main/java/net/worldseed/multipart/model_bones/BoneEntity.java @@ -5,7 +5,6 @@ import net.minestom.server.entity.EntityType; import net.minestom.server.entity.LivingEntity; import net.minestom.server.entity.Player; -import net.minestom.server.network.packet.server.LazyPacket; import net.minestom.server.network.packet.server.play.SpawnEntityPacket; import net.minestom.server.tag.Tag; import net.worldseed.multipart.GenericModel; @@ -29,7 +28,7 @@ public BoneEntity(@NotNull EntityType entityType, GenericModel model, String nam } @Override - public @NotNull Set getViewers() { + public @NotNull Set getViewers() { return model.getViewers(); } @@ -51,7 +50,7 @@ public void updateNewViewer(@NotNull Player player) { var spawnPacket = new SpawnEntityPacket(this.getEntityId(), this.getUuid(), this.getEntityType(), model.getPosition().withView(position.yaw(), 0), position.yaw(), 0, Vec.ZERO); player.sendPacket(spawnPacket); - player.sendPacket(new LazyPacket(this::getMetadataPacket)); + player.sendPacket(getMetadataPacket()); if (this.getEntityType() == EntityType.ZOMBIE || this.getEntityType() == EntityType.ARMOR_STAND) player.sendPacket(getEquipmentsPacket()); diff --git a/src/main/java/net/worldseed/multipart/model_bones/ModelBone.java b/src/main/java/net/worldseed/multipart/model_bones/ModelBone.java index ffee27e..8f830a3 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/ModelBone.java +++ b/src/main/java/net/worldseed/multipart/model_bones/ModelBone.java @@ -85,6 +85,13 @@ public interface ModelBone { default void teleport(Point position) {} + /** Show or hide this bone at runtime (removes/re-adds its display entity for the model's viewers). */ + default void setVisible(boolean visible) {} + + default boolean isVisible() { + return true; + } + default @NotNull Collection getChildren() { return List.of(); } diff --git a/src/main/java/net/worldseed/multipart/model_bones/display_entity/ModelBonePartDisplay.java b/src/main/java/net/worldseed/multipart/model_bones/display_entity/ModelBonePartDisplay.java index abb5bcf..fd921c1 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/display_entity/ModelBonePartDisplay.java +++ b/src/main/java/net/worldseed/multipart/model_bones/display_entity/ModelBonePartDisplay.java @@ -29,6 +29,12 @@ public class ModelBonePartDisplay extends ModelBoneImpl implements ModelBoneView private final List attached = new ArrayList<>(); private Entity baseStand; + // last transform sent to the client — used to skip re-sending an unchanged bone every tick + private Point lastTranslation; + private Vec lastScale; + private float[] lastRotation; + private boolean visible = true; + public ModelBonePartDisplay(Point pivot, String name, Point rotation, GenericModel model, float scale) { super(pivot, name, rotation, model, scale); @@ -47,11 +53,29 @@ public ModelBonePartDisplay(Point pivot, String name, Point rotation, GenericMod @Override public void addViewer(Player player) { - if (this.stand != null) this.stand.addViewer(player); + if (this.stand != null && this.visible) this.stand.addViewer(player); if (this.baseStand != null) this.baseStand.addViewer(player); this.attached.forEach(model -> model.addViewer(player)); } + @Override + public boolean isVisible() { + return this.visible; + } + + @Override + public void setVisible(boolean visible) { + if (this.visible == visible || this.stand == null) { + this.visible = visible; + return; + } + this.visible = visible; + for (Player viewer : this.model.getViewers()) { + if (visible) this.stand.addViewer(viewer); + else this.stand.removeViewer(viewer); + } + } + @Override public void removeGlowing() { if (this.stand != null) { @@ -194,17 +218,28 @@ public void draw() { if (this.stand != null) { var position = calculatePositionInternal(); - var scale = calculateScale(); + var scale = worldScale(); // memoized; == calculateScale() if (this.stand.getEntityMeta() instanceof ItemDisplayMeta meta) { - Quaternion q = calculateFinalAngle(new Quaternion(getPropagatedRotation())); - - meta.setNotifyAboutChanges(false); - meta.setTransformationInterpolationStartDelta(0); - meta.setScale(new Vec(scale.x() * this.scale, scale.y() * this.scale, scale.z() * this.scale)); - meta.setRightRotation(new float[]{(float) q.x(), (float) q.y(), (float) q.z(), (float) q.w()}); - meta.setTranslation(position); - meta.setNotifyAboutChanges(true); + Quaternion q = worldRotation(); // memoized; == calculateFinalAngle(new Quaternion(getPropagatedRotation())) + Vec scaleVec = new Vec(scale.x() * this.scale, scale.y() * this.scale, scale.z() * this.scale); + float[] rotation = {(float) q.x(), (float) q.y(), (float) q.z(), (float) q.w()}; + + // Only send a metadata packet when this bone's transform actually changed. A static + // bone (or one holding a keyframe) otherwise re-sends an identical packet to every + // viewer every tick; the client already holds the last transform. + if (!position.equals(this.lastTranslation) || !scaleVec.equals(this.lastScale) + || !java.util.Arrays.equals(rotation, this.lastRotation)) { + meta.setNotifyAboutChanges(false); + meta.setTransformationInterpolationStartDelta(0); + meta.setScale(scaleVec); + meta.setRightRotation(rotation); + meta.setTranslation(position); + meta.setNotifyAboutChanges(true); + this.lastTranslation = position; + this.lastScale = scaleVec; + this.lastRotation = rotation; + } attached.forEach(model -> { model.setPosition(this.model.getPosition().add(calculateGlobalRotation(position))); From 6fd9efa461beca6ce720b0a8ae2c7a794ee69cac Mon Sep 17 00:00:00 2001 From: Alexander Parent Date: Mon, 6 Jul 2026 17:21:48 -0400 Subject: [PATCH 5/6] resourcepack: sanitize resource locations, emit locators, harden parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sanitize/lower-case bone names where they become Minecraft ResourceLocations and on-disk model filenames, so models with uppercase/space bone names load on vanilla clients (issue #36 — was masked by Iris/Sodium). - Harden TextureGenerator's base64 decode (split at the data-URL comma instead of a fixed prefix; warn on non-embedded textures) (issue #65). - Emit Blockbench locators as cube-less position-only bones so they're queryable. - Guard null/missing keyframe fields so malformed models don't crash the pack build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FsZs24bSHE9fCDzFbwyhuH --- .../multipart/generator/GeoGenerator.java | 29 +++++++++++++++++-- .../multipart/generator/TextureGenerator.java | 15 ++++++++-- .../multipart/parser/ModelParser.java | 13 +++++++-- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/main/java/net/worldseed/resourcepack/multipart/generator/GeoGenerator.java b/src/main/java/net/worldseed/resourcepack/multipart/generator/GeoGenerator.java index 9ea01eb..1e40c04 100644 --- a/src/main/java/net/worldseed/resourcepack/multipart/generator/GeoGenerator.java +++ b/src/main/java/net/worldseed/resourcepack/multipart/generator/GeoGenerator.java @@ -43,9 +43,13 @@ private static List parseRecursive(JsonObject obj, Map parseRecursive(JsonObject obj, Map generate(JsonArray textures, Map parseLayer(String id, JsonValue texture, Map mcmetas, int height, int width) { JsonObject textureObj = texture.asJsonObject(); - String source = textureObj.getString("source", textureObj.getString("data_url", "data:image/png;base64,")); - byte[] data = Base64.getDecoder().decode(source.substring("data:image/png;base64,".length())); + String source = textureObj.getString("source", textureObj.getString("data_url", "")); + // Robustly extract embedded PNG bytes: split at the data-URL comma instead of assuming an exact + // "data:image/png;base64," prefix (a different mime, or a linked/relative texture, otherwise + // corrupts the decode or throws — see issue #65). Non-embedded textures can't be baked in. + int comma = source.indexOf(','); + byte[] data; + if (source.startsWith("data:") && comma >= 0) { + data = Base64.getDecoder().decode(source.substring(comma + 1)); + } else { + System.err.println("[WSEE] texture '" + textureObj.getString("name", "?") + + "' has no embedded base64 data (source not a data URL); it will be blank in the pack."); + data = new byte[0]; + } String name = textureObj.getString("name"); JsonValue uuid = textureObj.get("uuid"); diff --git a/src/main/java/net/worldseed/resourcepack/multipart/parser/ModelParser.java b/src/main/java/net/worldseed/resourcepack/multipart/parser/ModelParser.java index fb41375..db33634 100644 --- a/src/main/java/net/worldseed/resourcepack/multipart/parser/ModelParser.java +++ b/src/main/java/net/worldseed/resourcepack/multipart/parser/ModelParser.java @@ -201,7 +201,7 @@ private static Map createIndividualModels(List bones, boneInfo.add("elements", elementsToJson(elements)); boneInfo.add("texture_size", textureSize); boneInfo.add("display", display(midOffset)); - modelInfo.put(boneName + ".json", boneInfo.build()); + modelInfo.put(sanitizeBone(boneName) + ".json", boneInfo.build()); } } @@ -400,12 +400,21 @@ private static Map getUV(JsonObject uv) { return res; } + /** Sanitize a bone name into a valid Minecraft ResourceLocation path segment ([a-z0-9._-]). + * Blockbench allows arbitrary bone names (uppercase, spaces, etc.); an unsanitized name such as + * "VERH2" makes the generated item model an illegal location and the client drops the whole model + * (issue #36 — masked by Iris/Sodium, broken on vanilla). Must be applied identically wherever the + * bone becomes a resource ref (here) AND the on-disk model filename, so the two stay consistent. */ + public static String sanitizeBone(String bone) { + return bone.toLowerCase(java.util.Locale.ROOT).replaceAll("[^a-z0-9._-]", "_"); + } + private static JsonObject createEntry(int threshold, String name, String state, String bone) { final JsonObjectBuilder entry = Json.createObjectBuilder(); final JsonObjectBuilder model = Json.createObjectBuilder(); model.add("type", "model"); - model.add("model", "worldseed:mobs/" + name + "/" + state + "/" + bone); + model.add("model", "worldseed:mobs/" + name + "/" + state + "/" + sanitizeBone(bone)); entry.add("threshold", threshold); entry.add("model", model); From bc2334301eef0c9425b770e45173cbe420385268 Mon Sep 17 00:00:00 2001 From: Alexander Parent Date: Mon, 6 Jul 2026 17:21:49 -0400 Subject: [PATCH 6/6] test: update the demo server + mobs to the new API - GemGolemMob/BulbasaurMob use setOwner + normal Minestom hit events instead of the removed custom events. - Main.java: drop the removed setTimeRate call (Minestom 26.1.2 time API change). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FsZs24bSHE9fCDzFbwyhuH --- src/test/java/Main.java | 1 - .../demo_models/bulbasaur/BulbasaurMob.java | 6 +----- .../demo_models/gem_golem/GemGolemMob.java | 18 ++++++++---------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/test/java/Main.java b/src/test/java/Main.java index 3911c88..5652a39 100644 --- a/src/test/java/Main.java +++ b/src/test/java/Main.java @@ -63,7 +63,6 @@ void main() throws Exception { lobby.setChunkSupplier(LightingChunk::new); lobby.enableAutoChunkLoad(true); lobby.setGenerator(unit -> unit.modifier().fillHeight(0, 1, Block.STONE)); - lobby.setTimeRate(0); instanceManager.registerInstance(lobby); // Commands diff --git a/src/test/java/demo_models/bulbasaur/BulbasaurMob.java b/src/test/java/demo_models/bulbasaur/BulbasaurMob.java index d2c18d0..c52f554 100644 --- a/src/test/java/demo_models/bulbasaur/BulbasaurMob.java +++ b/src/test/java/demo_models/bulbasaur/BulbasaurMob.java @@ -18,7 +18,6 @@ import net.minestom.server.utils.time.TimeUnit; import net.worldseed.multipart.animations.AnimationHandler; import net.worldseed.multipart.animations.AnimationHandlerImpl; -import net.worldseed.multipart.events.ModelDamageEvent; import org.jetbrains.annotations.NotNull; import org.jspecify.annotations.NonNull; @@ -38,10 +37,7 @@ public BulbasaurMob(Instance instance, Pos pos) { this.model = new BulbasaurModel(); model.init(instance, pos, 1f); - - model.eventNode().addListener(ModelDamageEvent.class, (event) -> - damage(event.getDamage().getType(), event.getDamage().getAmount()) - ); + model.setOwner(this); // hits on the model's hitboxes now arrive as a normal EntityDamageEvent on this entity this.animationHandler = new AnimationHandlerImpl(model); this.animationHandler.playRepeat("animation.bulbasaur.ground_idle"); diff --git a/src/test/java/demo_models/gem_golem/GemGolemMob.java b/src/test/java/demo_models/gem_golem/GemGolemMob.java index 661ea81..9e52712 100644 --- a/src/test/java/demo_models/gem_golem/GemGolemMob.java +++ b/src/test/java/demo_models/gem_golem/GemGolemMob.java @@ -23,10 +23,9 @@ import net.minestom.server.utils.time.TimeUnit; import net.worldseed.multipart.animations.AnimationHandler; import net.worldseed.multipart.animations.AnimationHandlerImpl; +import net.minestom.server.event.player.PlayerEntityInteractEvent; import net.worldseed.multipart.events.ModelControlEvent; -import net.worldseed.multipart.events.ModelDamageEvent; import net.worldseed.multipart.events.ModelDismountEvent; -import net.worldseed.multipart.events.ModelInteractEvent; import net.worldseed.multipart.model_bones.BoneEntity; import org.jetbrains.annotations.NotNull; import org.jspecify.annotations.NonNull; @@ -65,15 +64,8 @@ public GemGolemMob(Instance instance, Pos pos) { this.controlGoal = new GemGolemControlGoal(this, animationHandler); + model.setOwner(this); model.eventNode() - .addListener(ModelDamageEvent.class, event -> { - if (event.getDamage() instanceof EntityDamage entityDamage) { - if (model.getPassengers(SEAT).contains(entityDamage.getSource())) return; - } - - damage(event.getDamage().getType(), event.getDamage().getAmount()); - }) - .addListener(ModelInteractEvent.class, event -> model.mountEntity(SEAT, event.getInteracted())) .addListener(ModelDismountEvent.class, event -> model.dismountEntity(SEAT, event.rider())) .addListener(ModelControlEvent.class, event -> { var forward = 0; @@ -85,6 +77,12 @@ public GemGolemMob(Instance instance, Pos pos) { controlGoal.setJump(event.packet().jump()); }); + // Hits are now normal Minestom events on this entity: damage auto-applies via the damage() + // override below; a right-click to mount the seat arrives as a PlayerEntityInteractEvent. + MinecraftServer.getGlobalEventHandler().addListener(PlayerEntityInteractEvent.class, event -> { + if (event.getTarget() == this) model.mountEntity(SEAT, event.getPlayer()); + }); + addAIGroup( List.of( controlGoal,