diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 9035229f..00000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Publish to Reposilite - -on: - push: - branches: [ master ] - workflow_dispatch: - -permissions: - contents: read - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 25 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '25' - - - name: Make gradlew executable - run: chmod +x ./gradlew - - - name: Build & publish to Reposilite - env: - # Reposilite token, injected from repo/org secrets — never committed. - # Gradle maps ORG_GRADLE_PROJECT_ -> project property , - # which the `AtlasEngine` maven repo's PasswordCredentials consume. - ORG_GRADLE_PROJECT_AtlasEngineUsername: ${{ secrets.REPOSILITE_USERNAME }} - ORG_GRADLE_PROJECT_AtlasEnginePassword: ${{ secrets.REPOSILITE_PASSWORD }} - run: ./gradlew publish --no-daemon --stacktrace diff --git a/build.gradle.kts b/build.gradle.kts index b3021d4d..e3d63c76 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,7 +21,7 @@ publishing { publications.create("maven") { groupId = "net.worldseed.multipart" artifactId = "WorldSeedEntityEngine" - version = "12.0.1" + version = "11.5.6" from(components["java"]) } @@ -39,10 +39,8 @@ 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) @@ -52,8 +50,10 @@ dependencies { implementation(libs.javax.json.api) implementation(libs.javax.json) + + implementation(libs.mql) } tasks.test { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8d392561..d722efe7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,11 @@ [versions] java = "25" junit = "6.0.0-M2" -minestom = "2026.07.22-26.2" +minestom = "2026.05.17c-26.1.1" 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" } @@ -14,3 +15,4 @@ 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" } diff --git a/src/main/java/net/worldseed/gestures/EmotePlayer.java b/src/main/java/net/worldseed/gestures/EmotePlayer.java index b71d9050..91420e66 100644 --- a/src/main/java/net/worldseed/gestures/EmotePlayer.java +++ b/src/main/java/net/worldseed/gestures/EmotePlayer.java @@ -10,6 +10,8 @@ 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; @@ -45,8 +47,14 @@ protected void loadDefaultAnimations() { } }; - // 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.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); + }); 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 89ec0aea..4ee2142d 100644 --- a/src/main/java/net/worldseed/multipart/GenericModel.java +++ b/src/main/java/net/worldseed/multipart/GenericModel.java @@ -94,15 +94,6 @@ 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 */ @@ -122,31 +113,9 @@ 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); - - /** Transform a point authored in generated Blockbench geometry space through a bone's - * current animation hierarchy into world space. */ - Point getBonePoint(String boneName, Point geometryPoint); - @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(); @@ -162,22 +131,6 @@ 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); @@ -204,17 +157,6 @@ 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 d6340606..e1dc0089 100644 --- a/src/main/java/net/worldseed/multipart/GenericModelImpl.java +++ b/src/main/java/net/worldseed/multipart/GenericModelImpl.java @@ -55,7 +55,6 @@ public abstract class GenericModelImpl implements GenericModel { private Pos position; private double globalRotation; private double pitch; - private float globalScale = 1; protected record ModelBoneInfo(String name, Point pivot, Point rotation, JsonArray cubes, GenericModel model, float scale) { @@ -132,7 +131,6 @@ public void init(@Nullable Instance instance, @NotNull Pos position) { public void init(@Nullable Instance instance, @NotNull Pos position, float scale) { this.instance = instance; this.position = position; - this.globalScale = scale; JsonObject loadedModel = ModelLoader.loadModel(getId()); this.setGlobalRotation(position.yaw()); @@ -153,7 +151,6 @@ public void init(@Nullable Instance instance, @NotNull Pos position, float scale @Override public void setGlobalScale(float scale) { - this.globalScale = scale; for (ModelBone modelBonePart : this.parts.values()) { modelBonePart.setGlobalScale(scale); } @@ -188,11 +185,6 @@ 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(); @@ -239,18 +231,6 @@ 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); @@ -262,7 +242,6 @@ 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(); @@ -285,30 +264,6 @@ 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 Point getBonePoint(String boneName, Point geometryPoint) { - ModelBone found = this.parts.get(boneName); - if (found == null || geometryPoint == null) return null; - // Generated geometry is quarter-scale and mirrors Blockbench X when bones load. - Point local = new Vec(-geometryPoint.x(), geometryPoint.y(), geometryPoint.z()); - Point animated = found.applyTransform(local).div(4).mul(globalScale); - Point world = ModelMath.rotate(animated, - new Vec(0, 180 - getGlobalRotation(), 0)); - return world.add(getPosition()); - } - @Override public void setHeadRotation(String name, double rotation) { ModelBone found = this.parts.get(name); @@ -415,9 +370,7 @@ public void addPartsAsPassengers(Player player) { @Override public @NotNull Set<@NotNull Player> getViewers() { - // 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); + return Set.copyOf(this.viewers); } @Override @@ -540,9 +493,9 @@ public void dismountEntity(String name, Entity entity) { } @Override - public List getPassengers(String name) { + public Set getPassengers(String name) { if (this.parts.get(name) instanceof RideableBone rideable) return rideable.getPassengers(); - return Collections.emptyList(); + return Collections.emptySet(); } @Override @@ -550,18 +503,6 @@ 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 a075c934..2748ae53 100644 --- a/src/main/java/net/worldseed/multipart/ModelEngine.java +++ b/src/main/java/net/worldseed/multipart/ModelEngine.java @@ -7,7 +7,6 @@ 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; @@ -18,6 +17,8 @@ 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; @@ -44,23 +45,17 @@ 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) { - Entity owner = bone.getModel().getOwner(); - if (owner != null && owner != event.getTarget()) { - EventDispatcher.call(new PlayerEntityInteractEvent(event.getPlayer(), owner, event.getHand(), event.getInteractPosition())); - } + ModelInteractEvent modelInteractEvent = new ModelInteractEvent(bone.getModel(), event, bone); + EventDispatcher.call(modelInteractEvent); } }); private static final EventListener<@NonNull EntityDamageEvent> entityDamageListener = EventListener.of(EntityDamageEvent.class, event -> { if (event.getEntity() instanceof BoneEntity bone) { - 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 - } + event.setCancelled(true); + ModelDamageEvent modelDamageEvent = new ModelDamageEvent(bone.getModel(), event, bone); + MinecraftServer.getGlobalEventHandler().call(modelDamageEvent); } }); private static Path modelPath; diff --git a/src/main/java/net/worldseed/multipart/ModelEntity.java b/src/main/java/net/worldseed/multipart/ModelEntity.java deleted file mode 100644 index 9b28dbfc..00000000 --- a/src/main/java/net/worldseed/multipart/ModelEntity.java +++ /dev/null @@ -1,79 +0,0 @@ -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/animations/AnimationEffect.java b/src/main/java/net/worldseed/multipart/animations/AnimationEffect.java deleted file mode 100644 index 4e974713..00000000 --- a/src/main/java/net/worldseed/multipart/animations/AnimationEffect.java +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 50c170a8..00000000 --- a/src/main/java/net/worldseed/multipart/animations/AnimationEffectHandler.java +++ /dev/null @@ -1,14 +0,0 @@ -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 088c18e7..536a3e6b 100644 --- a/src/main/java/net/worldseed/multipart/animations/AnimationHandler.java +++ b/src/main/java/net/worldseed/multipart/animations/AnimationHandler.java @@ -19,17 +19,6 @@ 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 * @@ -55,13 +44,6 @@ public interface AnimationHandler { */ void playOnce(String animation, Runnable cb) throws IllegalArgumentException; - /** - * Crossfade from the current repeat into a one-shot, then crossfade back over the final - * {@code blendTicks}. This is the only supported overlap: weights are complementary and no - * unrelated full-strength animations can run concurrently. - */ - void playOnce(String animation, int blendTicks, Runnable cb) throws IllegalArgumentException; - void playOnce(String animation, AnimationHandlerImpl.AnimationDirection direction, boolean override, Runnable cb) throws IllegalArgumentException; /** @@ -93,18 +75,6 @@ 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 1fc07efa..38047d57 100644 --- a/src/main/java/net/worldseed/multipart/animations/AnimationHandlerImpl.java +++ b/src/main/java/net/worldseed/multipart/animations/AnimationHandlerImpl.java @@ -2,13 +2,7 @@ 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; @@ -25,25 +19,13 @@ public class AnimationHandlerImpl implements AnimationHandler { private final Map animations = new ConcurrentHashMap<>(); private final TreeMap repeating = new TreeMap<>(); - /** The loop requested most recently by the caller; map priority must never pick locomotion state. */ - private ModelAnimation activeRepeating; private String playingOnce = null; - private int playingOnceBlendTicks = 0; 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; - if (model.getParts().isEmpty()) { - throw new IllegalStateException( - "The model must be initialized before creating its AnimationHandler; " + - "otherwise animation channels cannot bind to bones."); - } loadDefaultAnimations(); this.task = MinecraftServer.getSchedulerManager().scheduleTask(this::tick, TaskSchedule.immediate(), TaskSchedule.tick(1), ExecutionType.TICK_START); } @@ -62,8 +44,6 @@ 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<>(); @@ -79,19 +59,19 @@ public void registerAnimation(String name, JsonElement animation, int priority) boolean animated = false; - if (hasKeyframes(animationRotation)) { + if (animationRotation != null) { animated = true; - BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationRotation, ModelLoader.AnimationType.ROTATION, length, looping); + BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationRotation, ModelLoader.AnimationType.ROTATION, length); animationSet.add(boneAnimation); } - if (hasKeyframes(animationPosition)) { + if (animationPosition != null) { animated = true; - BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationPosition, ModelLoader.AnimationType.TRANSLATION, length, looping); + BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationPosition, ModelLoader.AnimationType.TRANSLATION, length); animationSet.add(boneAnimation); } - if (hasKeyframes(animationScale)) { + if (animationScale != null) { animated = true; - BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationScale, ModelLoader.AnimationType.SCALE, length, looping); + BoneAnimationImpl boneAnimation = new BoneAnimationImpl(model.getId(), name, boneName, bone, animationScale, ModelLoader.AnimationType.SCALE, length); animationSet.add(boneAnimation); } @@ -100,38 +80,7 @@ public void registerAnimation(String name, JsonElement animation, int priority) } } - 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); - } - - private static boolean hasKeyframes(JsonElement channel) { - return channel != null && channel.isJsonObject() && !channel.getAsJsonObject().isEmpty(); - } - - @Override - public void setEffectHandler(AnimationEffectHandler handler) { - this.effectHandler = handler; + animations.put(name, new ModelAnimationClassic(name, (int) (length * 20), priority, animationSet, animatedBones)); } @Override @@ -143,55 +92,29 @@ 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); - ModelAnimation existing = this.activeRepeating; - if (existing == modelAnimation && modelAnimation.weight() >= 0.999) return; - - // 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); - this.activeRepeating = 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) throw new IllegalArgumentException("Animation " + animation + " does not exist"); var modelAnimation = this.animations.get(animation); - if (this.activeRepeating == modelAnimation && modelAnimation.direction() == direction) return; + if (this.repeating.containsKey(this.animationPriorities().get(animation)) + && modelAnimation.direction() == direction) return; modelAnimation.setDirection(direction); this.repeating.put(this.animationPriorities().get(animation), modelAnimation); - this.activeRepeating = modelAnimation; - this.repeating.values().forEach(v -> { - if (v != modelAnimation) v.stop(); - }); - if (playingOnce == null) { - modelAnimation.play(false); + var top = this.repeating.firstEntry(); + + if (top != null && animation.equals(top.getValue().name())) { //The animation you want to play is the highest priority + this.repeating.values().forEach(v -> { + if (!v.name().equals(animation)) { //Stop all lower priority animations to ensure the correct one is playing + v.stop(); //The extra loop seemed redundant, please let me know if this breaks something + } + }); + if (playingOnce == null) { + modelAnimation.play(false); //Start the repeating animation if no playOnce animation is currently playing + } } } @@ -203,9 +126,15 @@ public void stopRepeat(String animation) throws IllegalArgumentException { modelAnimation.stop(); //Stop the highest priority repeating animation int priority = this.animationPriorities().get(animation); + + Map.Entry currentTop = this.repeating.firstEntry(); + this.repeating.remove(priority); - if (activeRepeating == modelAnimation) { - activeRepeating = null; + + Map.Entry firstEntry = this.repeating.firstEntry(); + + if (this.playingOnce == null && firstEntry != null && currentTop != null && !firstEntry.getKey().equals(currentTop.getKey())) { + firstEntry.getValue().play(false); //Restart the new highest priority repeating animation } } @@ -214,26 +143,12 @@ public void playOnce(String animation, Runnable cb) throws IllegalArgumentExcept this.playOnce(animation, true, cb); } - @Override - public void playOnce(String animation, int blendTicks, Runnable cb) throws IllegalArgumentException { - if (blendTicks <= 0) { - playOnce(animation, cb); - return; - } - playOnceInternal(animation, AnimationDirection.FORWARD, true, blendTicks, cb); - } - public void playOnce(String animation, boolean override, Runnable cb) throws IllegalArgumentException { this.playOnce(animation, AnimationDirection.FORWARD, override, cb); } @Override public void playOnce(String animation, AnimationDirection direction, boolean override, Runnable cb) throws IllegalArgumentException { - playOnceInternal(animation, direction, override, 0, cb); - } - - private void playOnceInternal(String animation, AnimationDirection direction, boolean override, - int blendTicks, Runnable cb) throws IllegalArgumentException { if (this.animationPriorities().get(animation) == null) throw new IllegalArgumentException("Animation " + animation + " does not exist"); @@ -263,25 +178,15 @@ private void playOnceInternal(String animation, AnimationDirection direction, bo modelAnimation.stop(); } playingOnce = animation; - playingOnceBlendTicks = Math.min(blendTicks, Math.max(0, modelAnimation.animationTime() / 3)); this.callbacks.put(animation, cb); this.callbackTimers.put(animation, modelAnimation.animationTime()); - if (playingOnceBlendTicks > 0) { - modelAnimation.setWeight(0); - modelAnimation.play(false); - modelAnimation.blendTo(1, playingOnceBlendTicks); - } else { - modelAnimation.setWeight(1); - modelAnimation.play(false); - } + modelAnimation.play(false); Set animatedBones = modelAnimation.getAnimatedBones(); this.repeating.values().forEach(v -> { if (!v.name().equals(animation)) { - if (playingOnceBlendTicks > 0 && override) { - v.blendTo(0, playingOnceBlendTicks); - } else if (override) { + if (override) { v.stop(); //Stop all repeating animations } else { v.stop(animatedBones); //Stop all 'animatedBones' for all repeating animations @@ -298,11 +203,9 @@ private void tick() { if (entry.getValue() <= 0) { //All ticks were removed so playOnce should end if (this.playingOnce != null && this.playingOnce.equals(entry.getKey())) { - ModelAnimation repeat = this.activeRepeating; - if (repeat != null) { - if (playingOnceBlendTicks <= 0) { - repeat.play(true); - } + Map.Entry firstEntry = this.repeating.firstEntry(); + if (firstEntry != null) { + firstEntry.getValue().play(true); //Restart or resume the highest priority repeating animation } this.playingOnce = null; } @@ -316,15 +219,6 @@ private void tick() { if (cb != null) cb.run(); //Run 'callback' runnable } else { if (modelAnimation.direction() != AnimationDirection.PAUSE) { - if (playingOnceBlendTicks > 0 && entry.getValue() == playingOnceBlendTicks) { - modelAnimation.blendTo(0, playingOnceBlendTicks); - ModelAnimation repeat = this.activeRepeating; - if (repeat != null) { - repeat.setWeight(0); - repeat.play(true); - repeat.blendTo(1, playingOnceBlendTicks); - } - } callbackTimers.put(entry.getKey(), entry.getValue() - 1); //Countdown 1 tick until it reaches 0 during playOnce animation } } @@ -336,106 +230,11 @@ 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 (playingOnce != null && playingOnceBlendTicks > 0) return false; - 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 a9d0f01c..964f208b 100644 --- a/src/main/java/net/worldseed/multipart/animations/BoneAnimation.java +++ b/src/main/java/net/worldseed/multipart/animations/BoneAnimation.java @@ -25,8 +25,4 @@ 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 4b8b86e3..6e771db6 100644 --- a/src/main/java/net/worldseed/multipart/animations/BoneAnimationImpl.java +++ b/src/main/java/net/worldseed/multipart/animations/BoneAnimationImpl.java @@ -21,18 +21,15 @@ 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, boolean looping) { + BoneAnimationImpl(String modelName, String animationName, String boneName, ModelBone bone, JsonElement keyframes, ModelLoader.AnimationType animationType, double length) { 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) { @@ -74,24 +71,14 @@ 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 = looping ? 0 : (short) length; // loop, or hold last frame + if (tick > length && length != 0) tick = 0; } else if (direction == AnimationHandlerImpl.AnimationDirection.BACKWARD) { tick--; - if (tick < 0 && length != 0) tick = looping ? (short) length : 0; // loop, or hold first frame + if (tick < 0 && length != 0) tick = (short) length; } } } @@ -146,12 +133,10 @@ 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 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; + MQLPoint point = ModelEngine.getMQLPos(obj.get("post").getAsJsonArray().get(0)).orElse(MQLPoint.ZERO); String lerp = entry.getValue().getAsJsonObject().get("lerp_mode").getAsString(); if (lerp == null) lerp = "linear"; - transform.put(time, new PointInterpolation(pre, post, lerp)); + transform.put(time, new PointInterpolation(point, lerp)); } else { MQLPoint point = ModelEngine.getMQLPos(obj.get("post").getAsJsonArray()).orElse(MQLPoint.ZERO); String lerp = entry.getValue().getAsJsonObject().get("lerp_mode").getAsString(); @@ -207,14 +192,6 @@ public short getTick() { return tick; } - /** - * 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 - } + public record PointInterpolation(MQLPoint p, String lerp) { } } diff --git a/src/main/java/net/worldseed/multipart/animations/CachedFrameProvider.java b/src/main/java/net/worldseed/multipart/animations/CachedFrameProvider.java index 2f927312..f77ae7d2 100644 --- a/src/main/java/net/worldseed/multipart/animations/CachedFrameProvider.java +++ b/src/main/java/net/worldseed/multipart/animations/CachedFrameProvider.java @@ -19,18 +19,11 @@ public CachedFrameProvider(int length, LinkedHashMap calculateAllTransforms(double animationTime, LinkedHashMap t, ModelLoader.AnimationType type) { Map transform = new HashMap<>(); - // 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; + int ticks = (int) (animationTime * 20); for (int i = 0; i <= ticks; i++) { var p = calculateTransform(i, t, type, animationTime); - // GeoGenerator stores bones at 1/4 of the source Blockbench coordinates, then the - // generated item model's display transform scales them back by 4. Animation keyframes - // are still raw Blockbench coordinates, so convert them to that same 1/4 geometry - // space here. ModelBonePartDisplay's /4 then converts both to Minecraft blocks: - // source units * 1/4 / 4 = source units / 16. + if (type == ModelLoader.AnimationType.TRANSLATION) p = p.div(4); transform.put((short) i, p); } @@ -45,9 +38,7 @@ private Point calculateTransform(int tick, LinkedHashMap - *
  • 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; + } - static Point interpolateRotation(double time, LinkedHashMap transform, double animationTime) { - return interpolate(time, transform, Vec.ZERO); + return new StartEnd(lastPoint, lastPoint, lastTime, animationTime); } - static Point interpolateTranslation(double time, LinkedHashMap transform, double animationTime) { - return interpolate(time, transform, Vec.ZERO); - } - - public static Point interpolateScale(double time, LinkedHashMap transform, double animationTime) { - return interpolate(time, transform, Vec.ONE); + 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); } - 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(); + static Point interpolateRotation(double time, LinkedHashMap transform, double animationTime) { + StartEnd points = getStartEnd(time, transform, animationTime); + if (points == null) return Vec.ZERO; - // 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 timeDiff = points.et - points.st; - if (time <= times.get(0) || i >= n - 1) return pre(transform, times.get(i), time); // before first / past last -> hold + if (timeDiff == 0) + return points.s.p().evaluate(time); - 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); + double timePercent = (time - points.st) / timeDiff; - 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 + if (points.s.lerp().equals("linear")) { + Vec ps = points.s.p().evaluate(time).asVec(); + Vec pe = points.e.p().evaluate(time).asVec(); - Vec end = pre(transform, times.get(i + 1), time); // value approaching keyframe i+1 - if (isSmooth(a.lerp()) || isSmooth(b.lerp())) { - 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 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); } - return start.lerp(end, alpha); // linear } - private static boolean isSmooth(String lerp) { - return "catmullrom".equals(lerp); - } + static Point interpolateTranslation(double time, LinkedHashMap transform, double animationTime) { + StartEnd points = getStartEnd(time, transform, animationTime); + if (points == null) return Vec.ZERO; - // 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(); + return getPoint(time, points); } - // 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(); + 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); } - /** 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)); + 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(); + + return ps.lerp(pe, timePercent); } - 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); + record StartEnd(BoneAnimationImpl.PointInterpolation s, BoneAnimationImpl.PointInterpolation e, double st, + double et) { } } diff --git a/src/main/java/net/worldseed/multipart/animations/ModelAnimation.java b/src/main/java/net/worldseed/multipart/animations/ModelAnimation.java index fa3e16e5..a5a7b5a1 100644 --- a/src/main/java/net/worldseed/multipart/animations/ModelAnimation.java +++ b/src/main/java/net/worldseed/multipart/animations/ModelAnimation.java @@ -22,32 +22,4 @@ 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 c161d799..44b44ced 100644 --- a/src/main/java/net/worldseed/multipart/animations/ModelAnimationClassic.java +++ b/src/main/java/net/worldseed/multipart/animations/ModelAnimationClassic.java @@ -11,21 +11,14 @@ 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, boolean looping) { + public ModelAnimationClassic(String name, int animationTime, int priority, HashSet animationSet, HashSet animatedBones) { 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 @@ -54,40 +47,9 @@ 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 @@ -109,34 +71,11 @@ 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/events/ModelDamageEvent.java b/src/main/java/net/worldseed/multipart/events/ModelDamageEvent.java new file mode 100644 index 00000000..ea77527b --- /dev/null +++ b/src/main/java/net/worldseed/multipart/events/ModelDamageEvent.java @@ -0,0 +1,104 @@ +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 new file mode 100644 index 00000000..612dd5dc --- /dev/null +++ b/src/main/java/net/worldseed/multipart/events/ModelInteractEvent.java @@ -0,0 +1,46 @@ +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 47e929e4..f9631692 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/BoneEntity.java +++ b/src/main/java/net/worldseed/multipart/model_bones/BoneEntity.java @@ -5,6 +5,7 @@ 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; @@ -50,7 +51,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(getMetadataPacket()); + player.sendPacket(new LazyPacket(this::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 8f830a32..ffee27e6 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/ModelBone.java +++ b/src/main/java/net/worldseed/multipart/model_bones/ModelBone.java @@ -85,13 +85,6 @@ 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/ModelBoneImpl.java b/src/main/java/net/worldseed/multipart/model_bones/ModelBoneImpl.java index 7b9dd62e..79139776 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/ModelBoneImpl.java +++ b/src/main/java/net/worldseed/multipart/model_bones/ModelBoneImpl.java @@ -31,21 +31,6 @@ 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; @@ -116,7 +101,7 @@ public Point applyTransform(Point p) { if (currentAnimation != null && currentAnimation.isPlaying()) { if (currentAnimation.getType() == AnimationType.TRANSLATION) { var calculatedTransform = currentAnimation.getTransform(); - endPos = endPos.add(calculatedTransform.mul(currentAnimation.weight())); // blend weight + endPos = endPos.add(calculatedTransform); } } } @@ -128,37 +113,35 @@ public Point applyTransform(Point p) { return endPos; } - /** 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; + public Point getPropagatedRotation() { + Point netTransform = Vec.ZERO; + for (BoneAnimation currentAnimation : this.allAnimations) { if (currentAnimation != null && currentAnimation.isPlaying()) { - 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) + if (currentAnimation.getType() == AnimationType.ROTATION) { + Point calculatedTransform = currentAnimation.getTransform(); + netTransform = netTransform.add(calculatedTransform); } } } - this.cachedPropagatedRotation = this.rotation.add(rot); - this.cachedPropagatedScale = scale; - this.propFrame = globalDrawFrame; - } - public Point getPropagatedRotation() { - computePropagated(); - return this.cachedPropagatedRotation; + return this.rotation.add(netTransform); } @Override public Point getPropagatedScale() { - computePropagated(); - return this.cachedPropagatedScale; + 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; } @Override @@ -180,31 +163,6 @@ 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/bone_types/RideableBone.java b/src/main/java/net/worldseed/multipart/model_bones/bone_types/RideableBone.java index b7bdd123..9748ab26 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/bone_types/RideableBone.java +++ b/src/main/java/net/worldseed/multipart/model_bones/bone_types/RideableBone.java @@ -3,10 +3,10 @@ import net.minestom.server.entity.Entity; import net.worldseed.multipart.model_bones.ModelBone; -import java.util.List; +import java.util.Set; public interface RideableBone extends ModelBone { void addPassenger(Entity entity); void removePassenger(Entity entity); - List getPassengers(); + Set getPassengers(); } 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 205d777c..174eafa9 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.mul(currentAnimation.weight())); // blend weight + netTransform = netTransform.add(calculatedTransform); } } } 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 56b62361..abb5bcf6 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,12 +29,6 @@ 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); @@ -48,39 +42,16 @@ public ModelBonePartDisplay(Point pivot, String name, Point rotation, GenericMod itemMeta.setTransformationInterpolationDuration(2); itemMeta.setPosRotInterpolationDuration(2); itemMeta.setViewRange(1000); - // Large multipart models often shade nearly black because every display samples light - // only at its tiny carrier/root position, even when the visible bone is many blocks - // away. Full display brightness preserves the authored texture instead of turning the - // entire model into a silhouette. - itemMeta.setBrightness(15, 15); } } @Override public void addViewer(Player player) { - if (this.stand != null && this.visible) this.stand.addViewer(player); + if (this.stand != null) 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) { @@ -214,10 +185,7 @@ public Point calculateScale() { @Override public void teleport(Point position) { - // Model roots move every tick. A teleport emits an absolute position sync, which makes - // passenger displays visibly snap between server ticks. Let Minestom choose relative - // movement packets instead so the vanilla client's entity interpolation can do its job. - if (this.baseStand != null) this.baseStand.refreshPosition(new Pos(position), true); + if (this.baseStand != null) this.baseStand.teleport(new Pos(position)); } public void draw() { @@ -226,28 +194,17 @@ public void draw() { if (this.stand != null) { var position = calculatePositionInternal(); - var scale = worldScale(); // memoized; == calculateScale() + var scale = calculateScale(); if (this.stand.getEntityMeta() instanceof ItemDisplayMeta meta) { - 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; - } + 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); attached.forEach(model -> { model.setPosition(this.model.getPosition().add(calculateGlobalRotation(position))); @@ -292,9 +249,6 @@ public void setState(String state) { @Override public Point getPosition() { - // Item-display passengers inherit the root stand's global yaw. Locator consumers - // (projectiles, particles, bone hit detection) need the same world-space rotation; - // returning the unrotated local translation made gameplay bones disagree with visuals. - return calculateGlobalRotation(calculatePositionInternal()).add(model.getPosition()); + return calculatePositionInternal().add(model.getPosition()); } } diff --git a/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneHitbox.java b/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneHitbox.java index 4b6627c7..45db13fd 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneHitbox.java +++ b/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneHitbox.java @@ -221,7 +221,7 @@ public Pos calculatePosition() { p = applyTransform(p); p = calculateGlobalRotation(p); - return Pos.fromPoint(p).div(4).mul(scale); + return p.asPos().div(4).mul(scale); } @Override diff --git a/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneSeat.java b/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneSeat.java index c683c7af..c2403477 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneSeat.java +++ b/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneSeat.java @@ -18,6 +18,7 @@ import net.worldseed.multipart.model_bones.bone_types.RideableBone; import java.util.List; +import java.util.Set; import java.util.concurrent.CompletableFuture; public class ModelBoneSeat extends ModelBoneImpl implements RideableBone { @@ -155,7 +156,7 @@ public void removePassenger(Entity entity) { } @Override - public List getPassengers() { + public Set getPassengers() { return this.stand.getPassengers(); } } diff --git a/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneVFX.java b/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneVFX.java index 9f515d86..417d442e 100644 --- a/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneVFX.java +++ b/src/main/java/net/worldseed/multipart/model_bones/misc/ModelBoneVFX.java @@ -64,7 +64,7 @@ public Pos calculatePosition() { p = applyTransform(p); p = calculateGlobalRotation(p); - Pos endPos = Pos.fromPoint(p); + Pos endPos = p.asPos(); return endPos .div(4, 4, 4).mul(scale) diff --git a/src/main/java/net/worldseed/multipart/mql/MQLData.java b/src/main/java/net/worldseed/multipart/mql/MQLData.java index f4f174ca..0a3fedab 100644 --- a/src/main/java/net/worldseed/multipart/mql/MQLData.java +++ b/src/main/java/net/worldseed/multipart/mql/MQLData.java @@ -1,39 +1,16 @@ package net.worldseed.multipart.mql; -/** - * 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}. - */ +import net.hollowcube.mql.foreign.Query; + public class MQLData { private double time; - private double lifeTime; public void setTime(double time) { this.time = time; } - public void setLifeTime(double lifeTime) { - this.lifeTime = lifeTime; - } - - /** Seconds since the current animation started (resets each loop). */ + @Query 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 ad11847e..f3b2de0b 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; -/** A compiled Molang keyframe expression. Pure and reusable — the {@link MQLData} is supplied per call. */ -@FunctionalInterface +import net.hollowcube.mql.jit.MqlEnv; + public interface MQLEvaluator { - double evaluate(MQLData data); + double evaluate(@MqlEnv({"q", "query"}) 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 31327565..c13a014c 100644 --- a/src/main/java/net/worldseed/multipart/mql/MQLPoint.java +++ b/src/main/java/net/worldseed/multipart/mql/MQLPoint.java @@ -3,6 +3,7 @@ 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; @@ -92,19 +93,15 @@ 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 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); + 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(); } 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 deleted file mode 100644 index 9993508f..00000000 --- a/src/main/java/net/worldseed/multipart/mql/Molang.java +++ /dev/null @@ -1,220 +0,0 @@ -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/main/java/net/worldseed/resourcepack/multipart/generator/AnimationGenerator.java b/src/main/java/net/worldseed/resourcepack/multipart/generator/AnimationGenerator.java index ef08ac3f..72c19234 100644 --- a/src/main/java/net/worldseed/resourcepack/multipart/generator/AnimationGenerator.java +++ b/src/main/java/net/worldseed/resourcepack/multipart/generator/AnimationGenerator.java @@ -8,29 +8,16 @@ public class AnimationGenerator { public static JsonObject generate(JsonArray animationRaw) { - return generate(animationRaw, true); - } - - /** - * Convert Blockbench animations to the runtime animation format. - * - * @param legacyCoordinates {@code true} for pre-5.0 bbmodels, whose animation values use the - * legacy Bedrock coordinate convention - */ - public static JsonObject generate(JsonArray animationRaw, boolean legacyCoordinates) { JsonObjectBuilder animations = Json.createObjectBuilder(); if (animationRaw == null) return animations.build(); for (int i = 0; i < animationRaw.size(); i++) { JsonObject animation = animationRaw.getJsonObject(i); - String name = animation.getString("name", null); - if (name == null) continue; - JsonNumber lengthNumber = animation.getJsonNumber("length"); - double length = lengthNumber == null ? 0 : lengthNumber.doubleValue(); + String name = animation.getString("name"); + double length = animation.getJsonNumber("length").doubleValue(); JsonObjectBuilder bones = Json.createObjectBuilder(); - JsonArrayBuilder effects = Json.createArrayBuilder(); var foundAnimations = animation.getJsonObject("animators"); if (foundAnimations == null) continue; @@ -42,52 +29,25 @@ public static JsonObject generate(JsonArray animationRaw, boolean legacyCoordina 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", null); - if (boneName == null) continue; // malformed animator without a bone name + String boneName = animator.getString("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", null); - JsonNumber timeNumber = keyframe.getJsonNumber("time"); - JsonArray dataPoints = keyframe.getJsonArray("data_points"); - if (channel == null || timeNumber == null || dataPoints == null) continue; // skip malformed keyframe + String channel = keyframe.getString("channel"); + + double time = keyframe.getJsonNumber("time").doubleValue(); - double time = timeNumber.doubleValue(); - String interpolation = keyframe.getString("interpolation", "linear"); + String interpolation = keyframe.getString("interpolation"); JsonObject built = Json.createObjectBuilder() - .add("post", legacyCoordinates ? dataPoints : mirrorForRuntime(dataPoints, channel)) + .add("post", keyframe.getJsonArray("data_points")) .add("lerp_mode", interpolation) .build(); @@ -128,10 +88,9 @@ public static JsonObject generate(JsonArray animationRaw, boolean legacyCoordina } JsonObject built = Json.createObjectBuilder() - .add("loop", animation.getString("loop", "once").equals("loop")) + .add("loop", animation.getString("loop").equals("loop")) .add("animation_length", length) .add("bones", bones) - .add("effects", effects) .build(); animations.add(name, built); @@ -139,33 +98,4 @@ public static JsonObject generate(JsonArray animationRaw, boolean legacyCoordina return animations.build(); } - - private static JsonArray mirrorForRuntime(JsonArray points, String channel) { - boolean mirrorX = channel.equals("rotation") || channel.equals("position"); - boolean mirrorY = channel.equals("rotation"); - if (!mirrorX && !mirrorY) return points; - - JsonArrayBuilder result = Json.createArrayBuilder(); - for (JsonValue value : points) { - if (!(value instanceof JsonObject point)) { - result.add(value); - continue; - } - JsonObjectBuilder mirrored = Json.createObjectBuilder(point); - if (mirrorX && point.containsKey("x")) mirrored.add("x", negate(point.get("x"))); - if (mirrorY && point.containsKey("y")) mirrored.add("y", negate(point.get("y"))); - result.add(mirrored); - } - return result.build(); - } - - private static JsonValue negate(JsonValue value) { - if (value instanceof JsonNumber number) { - return Json.createValue(-number.doubleValue()); - } - if (value instanceof JsonString string) { - return Json.createValue("-(" + string.getString() + ")"); - } - return value; - } } 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 1e40c04c..9ea01ebe 100644 --- a/src/main/java/net/worldseed/resourcepack/multipart/generator/GeoGenerator.java +++ b/src/main/java/net/worldseed/resourcepack/multipart/generator/GeoGenerator.java @@ -43,13 +43,9 @@ private static List parseRecursive(JsonObject obj, Map parseRecursive(JsonObject obj, Map textures, String id, AdditionalStates additionalStates) { } - - static boolean usesLegacyAnimationCoordinates(String formatVersion) { - try { - String major = formatVersion.split("\\.", 2)[0]; - return Integer.parseInt(major) < 5; - } catch (NumberFormatException ignored) { - return false; - } - } } diff --git a/src/main/java/net/worldseed/resourcepack/multipart/generator/TextureGenerator.java b/src/main/java/net/worldseed/resourcepack/multipart/generator/TextureGenerator.java index af022c4c..e8444dad 100644 --- a/src/main/java/net/worldseed/resourcepack/multipart/generator/TextureGenerator.java +++ b/src/main/java/net/worldseed/resourcepack/multipart/generator/TextureGenerator.java @@ -34,19 +34,8 @@ public static 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", "")); - // 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 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 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 db33634e..ca1c3704 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(sanitizeBone(boneName) + ".json", boneInfo.build()); + modelInfo.put(boneName + ".json", boneInfo.build()); } } @@ -317,9 +317,14 @@ private static UV convertUV(UV uv, int width, int height, boolean inverse) { double ex = uv.x2 * (16.0 / width); double ey = uv.y2 * (16.0 / height); - if (inverse) - return new UV(ex + sx, ey + sy, sx, sy, uv.texture, uv.rotation); - return new UV(sx, sy, ex + sx, ey + sy, uv.texture, uv.rotation); + if (inverse) { + return new UV(clampUV(ex + sx), clampUV(ey + sy), clampUV(sx), clampUV(sy), uv.texture, uv.rotation); + } + return new UV(clampUV(sx), clampUV(sy), clampUV(ex + sx), clampUV(ey + sy), uv.texture, uv.rotation); + } + + private static double clampUV(double v) { + return Math.clamp(v, 0.0, 16.0); } private static JsonObject mappingsToJson() { @@ -400,21 +405,12 @@ 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 + "/" + sanitizeBone(bone)); + model.add("model", "worldseed:mobs/" + name + "/" + state + "/" + bone); entry.add("threshold", threshold); entry.add("model", model); diff --git a/src/test/java/Main.java b/src/test/java/Main.java index 5652a395..15c4b3f9 100644 --- a/src/test/java/Main.java +++ b/src/test/java/Main.java @@ -29,6 +29,7 @@ import net.minestom.server.timer.TaskSchedule; import net.minestom.server.utils.MathUtils; import net.minestom.server.world.DimensionType; +import net.minestom.server.world.clock.WorldClock; import net.worldseed.multipart.ModelEngine; import net.worldseed.resourcepack.PackBuilder; import org.apache.commons.io.FileUtils; @@ -63,6 +64,7 @@ void main() throws Exception { lobby.setChunkSupplier(LightingChunk::new); lobby.enableAutoChunkLoad(true); lobby.setGenerator(unit -> unit.modifier().fillHeight(0, 1, Block.STONE)); + lobby.clock(WorldClock.OVERWORLD).pause(); instanceManager.registerInstance(lobby); // Commands diff --git a/src/test/java/commands/PlayerEmoteCommand.java b/src/test/java/commands/PlayerEmoteCommand.java index e70512e0..96f08df7 100644 --- a/src/test/java/commands/PlayerEmoteCommand.java +++ b/src/test/java/commands/PlayerEmoteCommand.java @@ -2,8 +2,6 @@ import emotes.EmoteExample; import net.minestom.server.command.builder.Command; -import net.minestom.server.coordinate.Pos; -import net.minestom.server.coordinate.Vec; import net.minestom.server.entity.Player; import net.minestom.server.entity.PlayerSkin; @@ -13,14 +11,8 @@ public PlayerEmoteCommand() { setDefaultExecutor((sender, context) -> { final Player player = (Player) sender; - PlayerSkin skin = player.getSkin(); - if (skin == null) skin = PlayerSkin.fromUsername("Notch"); - if (skin == null) throw new IllegalStateException("Could not resolve a player skin for the emote demo"); - - Pos playerPos = player.getPosition(); - Vec forward = playerPos.direction().withY(0).normalize().mul(4); - Pos emotePos = new Pos(playerPos.add(forward), playerPos.yaw() + 180, 0); - new EmoteExample(player.getInstance(), emotePos, skin); + PlayerSkin skin = PlayerSkin.fromUsername("Sg_Voltage"); + new EmoteExample(player.getInstance(), player.getPosition(), skin); }); } -} +} \ No newline at end of file diff --git a/src/test/java/demo_models/bulbasaur/BulbasaurMob.java b/src/test/java/demo_models/bulbasaur/BulbasaurMob.java index c52f554e..d2c18d00 100644 --- a/src/test/java/demo_models/bulbasaur/BulbasaurMob.java +++ b/src/test/java/demo_models/bulbasaur/BulbasaurMob.java @@ -18,6 +18,7 @@ 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; @@ -37,7 +38,10 @@ public BulbasaurMob(Instance instance, Pos pos) { this.model = new BulbasaurModel(); model.init(instance, pos, 1f); - model.setOwner(this); // hits on the model's hitboxes now arrive as a normal EntityDamageEvent on this entity + + model.eventNode().addListener(ModelDamageEvent.class, (event) -> + damage(event.getDamage().getType(), event.getDamage().getAmount()) + ); 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 4a9f2de8..661ea817 100644 --- a/src/test/java/demo_models/gem_golem/GemGolemMob.java +++ b/src/test/java/demo_models/gem_golem/GemGolemMob.java @@ -23,9 +23,10 @@ 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; @@ -64,8 +65,15 @@ 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; @@ -77,12 +85,6 @@ 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, @@ -185,7 +187,7 @@ public void setSleeping(boolean sleeping) { } @Override - public @NotNull List getPassengers() { - return List.copyOf(model.getPassengers(SEAT)); + public @NotNull Set getPassengers() { + return model.getPassengers(SEAT); } } diff --git a/src/test/java/emotes/EmoteExample.java b/src/test/java/emotes/EmoteExample.java index 081f6ffd..3987ba3b 100644 --- a/src/test/java/emotes/EmoteExample.java +++ b/src/test/java/emotes/EmoteExample.java @@ -10,18 +10,11 @@ import java.util.Map; public class EmoteExample extends EmotePlayer { - private static final String ANIMATION_STRING = "{\"format_version\":\"1.8.0\",\"animations\":{\"dab\":{\"animation_length\":2,\"bones\":{\"Head\":{\"rotation\":{\"0.0\":[0,0,0],\"0.45\":[32.5,0,0],\"1.45\":[32.5,0,0],\"1.8\":[0,0,0]}},\"RightArm\":{\"rotation\":{\"0.0\":[0,0,0],\"0.45\":[-47.5,0,-100],\"0.85\":[0,0,100],\"1.25\":[-47.5,0,-100],\"1.8\":[0,0,0]}},\"LeftArm\":{\"rotation\":{\"0.0\":[0,0,0],\"0.45\":[0,0,-100],\"0.85\":[-47.5,0,100],\"1.25\":[0,0,-100],\"1.8\":[0,0,0]}},\"LeftLeg\":{\"rotation\":{\"0.0\":[0,0,0],\"1.45\":[10,0,0],\"1.8\":[0,0,0]}},\"Body\":{\"rotation\":{\"0.0\":[0,0,0],\"1.45\":[5,0,0],\"1.8\":[0,0,0]}}}},\"wave\":{\"animation_length\":4,\"bones\":{\"RightArm\":{\"rotation\":{\"0.0\":[0,0,0],\"0.3\":[0,0,137.5],\"4.0\":[0,0,0]},\"position\":{\"0.0\":[0,0,0],\"0.3\":[-0.25,0,0],\"4.0\":[0,0,0]}}}}},\"geckolib_format_version\":2}"; + private static final String ANIMATION_STRING = "{\"format_version\":\"1.8.0\",\"animations\":{\"dab\":{\"animation_length\":2,\"bones\":{\"Head\":{\"rotation\":{\"0.0\":[0,0,0],\"0.45\":[32.5,0,0],\"1.45\":[32.5,0,0],\"1.8\":[0,0,0]}},\"RightArm\":{\"rotation\":{\"0.0\":[0,0,0],\"0.45\":[-47.5,0,-100],\"0.85\":[0,0,100],\"1.25\":[-47.5,0,-100],\"1.8\":[0,0,0]}},\"LeftArm\":{\"rotation\":{\"0.0\":[0,0,0],\"0.45\":[0,0,-100],\"0.85\":[-47.5,0,100],\"1.25\":[0,0,-100],\"1.8\":[0,0,0]}},\"LeftLeg\":{\"rotation\":{\"0.0\":[0,0,0],\"1.45\":[10,0,0],\"1.8\":[0,0,0]}},\"Torso\":{\"rotation\":{\"0.0\":[0,0,0],\"1.45\":[5,0,0],\"1.8\":[0,0,0]}}}},\"wave\":{\"animation_length\":4,\"bones\":{\"right_arm\":{\"rotation\":{\"0.0\":[0,0,0],\"0.3\":[0,0,137.5],\"4.0\":[0,0,0]},\"position\":{\"0.0\":[0,0,0],\"0.3\":[-0.25,0,0],\"4.0\":[0,0,0]}}}}},\"geckolib_format_version\":2}"; private static final Map ANIMATIONS; static { - // playRepeat expects the source animation to opt into wrapping its bone timelines. - ANIMATIONS = ModelLoader.parseAnimations(ANIMATION_STRING.replace( - "\"dab\":{\"animation_length\"", - "\"dab\":{\"loop\":true,\"animation_length\"") - .replace("[-47.5,0,-100]", "[-47.5,0,-125]") - .replace("[0,0,-100]", "[0,0,-125]") - .replace("[0,0,100]", "[0,0,125]") - .replace("[-47.5,0,100]", "[-47.5,0,125]")); + ANIMATIONS = ModelLoader.parseAnimations(ANIMATION_STRING); } public EmoteExample(Instance instance, Pos pos, PlayerSkin skin) { diff --git a/src/test/java/events/PackEvent.java b/src/test/java/events/PackEvent.java index 254b4be3..5bd551dc 100644 --- a/src/test/java/events/PackEvent.java +++ b/src/test/java/events/PackEvent.java @@ -7,7 +7,6 @@ import net.minestom.server.MinecraftServer; import net.minestom.server.event.GlobalEventHandler; import net.minestom.server.event.player.AsyncPlayerConfigurationEvent; -import net.minestom.server.event.player.PlayerResourcePackStatusEvent; import net.minestom.server.timer.TaskSchedule; import java.io.*; @@ -27,8 +26,8 @@ private static void startHttpServer(File zipFile, String hash) throws IOExceptio server.start(); } - private static String calculateSha1(File file) throws NoSuchAlgorithmException, IOException { - MessageDigest digest = MessageDigest.getInstance("SHA-1"); + private static String calculateMD5(File file) throws NoSuchAlgorithmException, IOException { + MessageDigest digest = MessageDigest.getInstance("MD5"); try (InputStream is = new FileInputStream(file)) { byte[] buffer = new byte[8192]; int read; @@ -36,13 +35,13 @@ private static String calculateSha1(File file) throws NoSuchAlgorithmException, digest.update(buffer, 0, read); } } - byte[] sha1sum = digest.digest(); - BigInteger bigInt = new BigInteger(1, sha1sum); - return String.format("%040x", bigInt); + byte[] md5sum = digest.digest(); + BigInteger bigInt = new BigInteger(1, md5sum); + return String.format("%032x", bigInt); } public static void hook(GlobalEventHandler handler, File zipFile) throws IOException, NoSuchAlgorithmException { - String hash = calculateSha1(zipFile); + String hash = calculateMD5(zipFile); startHttpServer(zipFile, hash); handler.addListener(AsyncPlayerConfigurationEvent.class, event -> @@ -54,8 +53,6 @@ public static void hook(GlobalEventHandler handler, File zipFile) throws IOExcep event.getPlayer().sendResourcePacks(resourcePackInfo); }, TaskSchedule.tick(20), TaskSchedule.stop()) ); - handler.addListener(PlayerResourcePackStatusEvent.class, event -> - IO.println("Resource pack status for " + event.getPlayer().getUsername() + ": " + event.getStatus())); } static class PackHandler implements HttpHandler { diff --git a/src/test/java/net/worldseed/multipart/animations/AnimationEffectsTest.java b/src/test/java/net/worldseed/multipart/animations/AnimationEffectsTest.java deleted file mode 100644 index 76678f31..00000000 --- a/src/test/java/net/worldseed/multipart/animations/AnimationEffectsTest.java +++ /dev/null @@ -1,65 +0,0 @@ -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/CachedFrameProviderTest.java b/src/test/java/net/worldseed/multipart/animations/CachedFrameProviderTest.java deleted file mode 100644 index 0a34d575..00000000 --- a/src/test/java/net/worldseed/multipart/animations/CachedFrameProviderTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package net.worldseed.multipart.animations; - -import net.minestom.server.coordinate.Vec; -import net.worldseed.multipart.ModelLoader; -import net.worldseed.multipart.mql.MQLPoint; -import org.junit.jupiter.api.Test; - -import java.util.LinkedHashMap; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; - -class CachedFrameProviderTest { - @Test - void animationSecondsAdvanceAtTwentyFramesPerSecond() { - var keys = new LinkedHashMap(); - keys.put(0.0, new BoneAnimationImpl.PointInterpolation(new MQLPoint(0, 0, 0), "linear")); - keys.put(1.0, new BoneAnimationImpl.PointInterpolation(new MQLPoint(20, 0, 0), "linear")); - var frames = new CachedFrameProvider(20, keys, ModelLoader.AnimationType.TRANSLATION); - - assertEquals(0, frames.getFrame(0).x(), 1e-9); - // WSEE mirrors Blockbench X into Minecraft's coordinate convention. - assertEquals(-2.5, frames.getFrame(10).x(), 1e-9); - assertEquals(-5, frames.getFrame(20).x(), 1e-9); - assertNotEquals(frames.getFrame(0), frames.getFrame(10)); - } - - @Test - void emptyChannelsUseIdentityTransforms() { - var empty = new LinkedHashMap(); - var rotation = new CachedFrameProvider(20, empty, - ModelLoader.AnimationType.ROTATION).getFrame(10); - assertEquals(0, rotation.x(), 1e-9); - assertEquals(0, rotation.y(), 1e-9); - assertEquals(0, rotation.z(), 1e-9); - assertEquals(Vec.ONE, new CachedFrameProvider(20, empty, - ModelLoader.AnimationType.SCALE).getFrame(10)); - } -} diff --git a/src/test/java/net/worldseed/multipart/animations/InterpolatorTest.java b/src/test/java/net/worldseed/multipart/animations/InterpolatorTest.java deleted file mode 100644 index 766e33db..00000000 --- a/src/test/java/net/worldseed/multipart/animations/InterpolatorTest.java +++ /dev/null @@ -1,75 +0,0 @@ -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 bezierFallsBackToLinearWithoutExportedHandles() { - var t = map(0.0, kf(0, "bezier"), 1.0, kf(0, "bezier"), 2.0, kf(10, "bezier"), 3.0, kf(20, "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); - } -} diff --git a/src/test/java/net/worldseed/multipart/mql/MqlPointTest.java b/src/test/java/net/worldseed/multipart/mql/MqlPointTest.java deleted file mode 100644 index fe51fc14..00000000 --- a/src/test/java/net/worldseed/multipart/mql/MqlPointTest.java +++ /dev/null @@ -1,48 +0,0 @@ -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); - } -} diff --git a/src/test/java/net/worldseed/resourcepack/multipart/generator/AnimationRendererParityTest.java b/src/test/java/net/worldseed/resourcepack/multipart/generator/AnimationRendererParityTest.java deleted file mode 100644 index 6b2581f2..00000000 --- a/src/test/java/net/worldseed/resourcepack/multipart/generator/AnimationRendererParityTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package net.worldseed.resourcepack.multipart.generator; - -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.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class AnimationRendererParityTest { - @Test - void detectsTheSameLegacyVersionsAsTheRustRenderer() { - assertTrue(ModelGenerator.usesLegacyAnimationCoordinates("4.10")); - assertFalse(ModelGenerator.usesLegacyAnimationCoordinates("5.0")); - assertFalse(ModelGenerator.usesLegacyAnimationCoordinates("")); - } - - @Test - void modernCoordinatesCancelTheRuntimeLegacyMirror() { - JsonObject animation = animationWithPoints( - Json.createObjectBuilder().add("x", 10).add("y", 20).add("z", 30).build(), - Json.createObjectBuilder().add("x", "query.anim_time * 2").add("y", 2).add("z", 3).build()); - - JsonObject generated = AnimationGenerator.generate( - Json.createArrayBuilder().add(animation).build(), false); - JsonObject rotation = point(generated, "rotation"); - JsonObject position = point(generated, "position"); - - assertEquals(-10, rotation.getInt("x")); - assertEquals(-20, rotation.getInt("y")); - assertEquals(30, rotation.getInt("z")); - assertEquals("-(query.anim_time * 2)", position.getString("x")); - assertEquals(2, position.getInt("y")); - assertEquals(3, position.getInt("z")); - } - - @Test - void legacyCoordinatesPassThroughForTheRuntimeMirror() { - JsonObject animation = animationWithPoints( - Json.createObjectBuilder().add("x", 10).add("y", 20).add("z", 30).build(), - Json.createObjectBuilder().add("x", 1).add("y", 2).add("z", 3).build()); - - JsonObject generated = AnimationGenerator.generate( - Json.createArrayBuilder().add(animation).build(), true); - - assertEquals(10, point(generated, "rotation").getInt("x")); - assertEquals(1, point(generated, "position").getInt("x")); - } - - private static JsonObject animationWithPoints(JsonObject rotation, JsonObject position) { - JsonArray keyframes = Json.createArrayBuilder() - .add(keyframe("rotation", rotation)) - .add(keyframe("position", position)) - .build(); - JsonObject animator = Json.createObjectBuilder() - .add("name", "arm") - .add("type", "bone") - .add("keyframes", keyframes) - .build(); - return Json.createObjectBuilder() - .add("name", "wave") - .add("length", 1) - .add("animators", Json.createObjectBuilder().add("uuid", animator)) - .build(); - } - - private static JsonObject keyframe(String channel, JsonObject point) { - return Json.createObjectBuilder() - .add("channel", channel) - .add("time", 0) - .add("interpolation", "linear") - .add("data_points", Json.createArrayBuilder().add(point)) - .build(); - } - - private static JsonObject point(JsonObject generated, String channel) { - return generated.getJsonObject("wave") - .getJsonObject("bones") - .getJsonObject("arm") - .getJsonObject(channel) - .getJsonObject("0.0") - .getJsonArray("post") - .getJsonObject(0); - } -} diff --git a/src/test/resources/resourcepack_template/assets/minecraft/items/player_head.json b/src/test/resources/resourcepack_template/assets/minecraft/items/player_head.json index 1020565a..5af70fe2 100644 --- a/src/test/resources/resourcepack_template/assets/minecraft/items/player_head.json +++ b/src/test/resources/resourcepack_template/assets/minecraft/items/player_head.json @@ -11,7 +11,12 @@ "model": { "type": "minecraft:player_head" }, - "transformation": { "translation": [0.5, 0.0, 0.5], "left_rotation": [1.0, 0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0], "right_rotation": [0.0, 0.0, 0.0, 1.0] } + "transformation": { + "left_rotation": [1.0, 0.0, 0.0, -0.0], + "right_rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "translation": [0.5, 0.0, 0.5] + } } }, { @@ -22,7 +27,12 @@ "model": { "type": "minecraft:player_head" }, - "transformation": { "translation": [0.5, 0.0, 0.5], "left_rotation": [1.0, 0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0], "right_rotation": [0.0, 0.0, 0.0, 1.0] } + "transformation": { + "left_rotation": [1.0, 0.0, 0.0, -0.0], + "right_rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "translation": [0.5, 0.0, 0.5] + } } }, { @@ -33,7 +43,12 @@ "model": { "type": "minecraft:player_head" }, - "transformation": { "translation": [0.5, 0.0, 0.5], "left_rotation": [1.0, 0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0], "right_rotation": [0.0, 0.0, 0.0, 1.0] } + "transformation": { + "left_rotation": [1.0, 0.0, 0.0, -0.0], + "right_rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "translation": [0.5, 0.0, 0.5] + } } }, { @@ -44,7 +59,12 @@ "model": { "type": "minecraft:player_head" }, - "transformation": { "translation": [0.5, 0.0, 0.5], "left_rotation": [1.0, 0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0], "right_rotation": [0.0, 0.0, 0.0, 1.0] } + "transformation": { + "left_rotation": [1.0, 0.0, 0.0, -0.0], + "right_rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "translation": [0.5, 0.0, 0.5] + } } }, { @@ -55,7 +75,12 @@ "model": { "type": "minecraft:player_head" }, - "transformation": { "translation": [0.5, 0.0, 0.5], "left_rotation": [1.0, 0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0], "right_rotation": [0.0, 0.0, 0.0, 1.0] } + "transformation": { + "left_rotation": [1.0, 0.0, 0.0, -0.0], + "right_rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "translation": [0.5, 0.0, 0.5] + } } }, { @@ -66,7 +91,12 @@ "model": { "type": "minecraft:player_head" }, - "transformation": { "translation": [0.5, 0.0, 0.5], "left_rotation": [1.0, 0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0], "right_rotation": [0.0, 0.0, 0.0, 1.0] } + "transformation": { + "left_rotation": [1.0, 0.0, 0.0, -0.0], + "right_rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "translation": [0.5, 0.0, 0.5] + } } }, { @@ -77,7 +107,12 @@ "model": { "type": "minecraft:player_head" }, - "transformation": { "translation": [0.5, 0.0, 0.5], "left_rotation": [1.0, 0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0], "right_rotation": [0.0, 0.0, 0.0, 1.0] } + "transformation": { + "left_rotation": [1.0, 0.0, 0.0, -0.0], + "right_rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "translation": [0.5, 0.0, 0.5] + } } }, { @@ -88,7 +123,12 @@ "model": { "type": "minecraft:player_head" }, - "transformation": { "translation": [0.5, 0.0, 0.5], "left_rotation": [1.0, 0.0, 0.0, 0.0], "scale": [1.0, 1.0, 1.0], "right_rotation": [0.0, 0.0, 0.0, 1.0] } + "transformation": { + "left_rotation": [1.0, 0.0, 0.0, -0.0], + "right_rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "translation": [0.5, 0.0, 0.5] + } } } ] diff --git a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/head.json b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/head.json index c9d518ce..f11ae752 100644 --- a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/head.json +++ b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/head.json @@ -1 +1 @@ -{"parent":"minecraft:item/template_skull","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[0,7.5,0],"scale":[0.9375,0.9375,0.9375]}}} +{"parent":"builtin/entity","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[0,7.5,0],"scale":[0.9375,0.9375,0.9375]}}} \ No newline at end of file diff --git a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/left_arm.json b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/left_arm.json index f181bfd3..43dc67fe 100644 --- a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/left_arm.json +++ b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/left_arm.json @@ -1 +1 @@ -{"parent":"minecraft:item/template_skull","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[1,0,0],"scale":[0.46875,1.40625,0.46875]}}} +{"parent":"builtin/entity","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[1,0,0],"scale":[0.46875,1.40625,0.46875]}}} diff --git a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/left_leg.json b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/left_leg.json index 87db7440..9acc0358 100644 --- a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/left_leg.json +++ b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/left_leg.json @@ -1 +1 @@ -{"parent":"minecraft:item/template_skull","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[-0.1,0,0],"scale":[0.46875,1.40625,0.46875]}}} +{"parent":"builtin/entity","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[-0.1,0,0],"scale":[0.46875,1.40625,0.46875]}}} diff --git a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/right_arm.json b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/right_arm.json index f65e59db..ad68dfaf 100644 --- a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/right_arm.json +++ b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/right_arm.json @@ -1 +1 @@ -{"parent":"minecraft:item/template_skull","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[-1,0,0],"scale":[0.46875,1.40625,0.46875]}}} +{"parent":"builtin/entity","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[-1,0,0],"scale":[0.46875,1.40625,0.46875]}}} diff --git a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/right_leg.json b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/right_leg.json index f38cf88a..68f889e3 100644 --- a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/right_leg.json +++ b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/right_leg.json @@ -1 +1 @@ -{"parent":"minecraft:item/template_skull","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[0.1,0,0],"scale":[0.46875,1.40625,0.46875]}}} +{"parent":"builtin/entity","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[0.1,0,0],"scale":[0.46875,1.40625,0.46875]}}} diff --git a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/slim_left.json b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/slim_left.json index 1ee3f0ea..92ef5905 100644 --- a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/slim_left.json +++ b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/slim_left.json @@ -1 +1 @@ -{"parent":"minecraft:item/template_skull","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[5.125,0,0],"scale":[0.3515625,1.40625,0.46875]}}} +{"parent":"builtin/entity","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[5.125,0,0],"scale":[0.3515625,1.40625,0.46875]}}} \ No newline at end of file diff --git a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/slim_right.json b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/slim_right.json index 1cca30ce..a7d65587 100644 --- a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/slim_right.json +++ b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/slim_right.json @@ -1 +1 @@ -{"parent":"minecraft:item/template_skull","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[-5.125,0,0],"scale":[0.3515625,1.40625,0.46875]}}} +{"parent":"builtin/entity","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[-5.125,0,0],"scale":[0.3515625,1.40625,0.46875]}}} \ No newline at end of file diff --git a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/torso.json b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/torso.json index 35d448db..c67091f3 100644 --- a/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/torso.json +++ b/src/test/resources/resourcepack_template/assets/minecraft/models/custom/entities/player/torso.json @@ -1 +1 @@ -{"parent":"minecraft:item/template_skull","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[0,0,0],"scale":[0.9375,1.40625,0.46875]}}} +{"parent":"builtin/entity","display":{"thirdperson_righthand":{"rotation":[0,0,0],"translation":[0,0,0],"scale":[0.9375,1.40625,0.46875]}}} \ No newline at end of file diff --git a/src/test/resources/resourcepack_template/assets/minecraft/shaders/core/entity.fsh b/src/test/resources/resourcepack_template/assets/minecraft/shaders/core/entity.fsh index 07756cb3..c862837c 100644 --- a/src/test/resources/resourcepack_template/assets/minecraft/shaders/core/entity.fsh +++ b/src/test/resources/resourcepack_template/assets/minecraft/shaders/core/entity.fsh @@ -1,58 +1,56 @@ -#version 330 +#version 150 -#moj_import +#moj_import #moj_import uniform sampler2D Sampler0; -#ifdef DISSOLVE -uniform sampler2D DissolveMaskSampler; -#endif + +uniform float FogStart; +uniform float FogEnd; in float sphericalVertexDistance; in float cylindricalVertexDistance; -#ifdef PER_FACE_LIGHTING -in vec4 vertexPerFaceColorBack; -in vec4 vertexPerFaceColorFront; -#else in vec4 vertexColor; -#endif -#ifndef EMISSIVE in vec4 lightMapColor; -#endif -#ifndef NO_OVERLAY in vec4 overlayColor; -#endif in vec2 texCoord0; -in vec2 texCoord1; -flat in int playerPart; + out vec4 fragColor; +uniform mat4 ProjMat; +uniform mat3 IViewRotMat; + +in vec2 texCoord1; +in float part; + void main() { vec4 color = texture(Sampler0, texCoord0); -#ifdef ALPHA_CUTOUT - if (color.a < ALPHA_CUTOUT) discard; -#endif - if (color.a < 1.0 && playerPart > 0) { - vec4 under = texture(Sampler0, texCoord1); - if (color.a < 0.75 && int(gl_FragCoord.x + gl_FragCoord.y) % 2 == 0) discard; - color.rgb = mix(under.rgb, color.rgb, min(1.0, color.a * 2.0)); - color.a = 1.0; + + #ifdef ALPHA_CUTOUT + if (color.a < ALPHA_CUTOUT || abs(mod(part + 0.5, 1.0) - 0.5) > 0.001) { + discard; + } + #endif + + if (color.a < 1.0 && part > 0.5) { + vec4 color2 = texture(Sampler0, texCoord1); + if (color.a < 0.75 && int(gl_FragCoord.x + gl_FragCoord.y) % 2 == 0) { + discard; + } else { + color.rgb = mix(color2.rgb, color.rgb, min(1.0, color.a * 2)); + color.a = 1.0; + } } -#ifdef PER_FACE_LIGHTING - vec4 faceVertexColor = gl_FrontFacing ? vertexPerFaceColorFront : vertexPerFaceColorBack; -#else - vec4 faceVertexColor = vertexColor; -#endif -#ifdef DISSOLVE - if (faceVertexColor.a < texture(DissolveMaskSampler, texCoord0).a) discard; - faceVertexColor.a = 1.0; -#endif - color *= faceVertexColor * ColorModulator; -#ifndef NO_OVERLAY - color.rgb = mix(overlayColor.rgb, color.rgb, overlayColor.a); -#endif -#ifndef EMISSIVE - color *= lightMapColor; -#endif + + color *= vertexColor * ColorModulator; + + #ifndef NO_OVERLAY + color.rgb = mix(overlayColor.rgb, color.rgb, overlayColor.a); + #endif + + #ifndef EMISSIVE + color *= lightMapColor; + #endif + fragColor = apply_fog(color, sphericalVertexDistance, cylindricalVertexDistance, FogEnvironmentalStart, FogEnvironmentalEnd, FogRenderDistanceStart, FogRenderDistanceEnd, FogColor); } diff --git a/src/test/resources/resourcepack_template/assets/minecraft/shaders/core/entity.vsh b/src/test/resources/resourcepack_template/assets/minecraft/shaders/core/entity.vsh index e8ca1074..7676df3e 100644 --- a/src/test/resources/resourcepack_template/assets/minecraft/shaders/core/entity.vsh +++ b/src/test/resources/resourcepack_template/assets/minecraft/shaders/core/entity.vsh @@ -1,12 +1,9 @@ -#version 330 +#version 150 -#if defined(PER_FACE_LIGHTING) || !defined(NO_CARDINAL_LIGHTING) -#moj_import -#endif -#moj_import +#moj_import +#moj_import #moj_import #moj_import -#moj_import in vec3 Position; in vec4 Color; @@ -15,97 +12,153 @@ in ivec2 UV1; in ivec2 UV2; in vec3 Normal; -uniform sampler2D Sampler0; -#ifndef NO_OVERLAY uniform sampler2D Sampler1; -#endif -#ifndef EMISSIVE uniform sampler2D Sampler2; -#endif +uniform int FogShape; out float sphericalVertexDistance; out float cylindricalVertexDistance; -#ifdef PER_FACE_LIGHTING -out vec4 vertexPerFaceColorBack; -out vec4 vertexPerFaceColorFront; -#else out vec4 vertexColor; -#endif -#ifndef EMISSIVE out vec4 lightMapColor; -#endif -#ifndef NO_OVERLAY out vec4 overlayColor; -#endif out vec2 texCoord0; + +uniform sampler2D Sampler0; out vec2 texCoord1; -flat out int playerPart; +out float part; #define SPACING 1024.0 #define MAXRANGE (0.5 * SPACING) const vec4[] subuvs = vec4[]( - vec4(4,0,8,4), vec4(8,0,12,4), vec4(0,4,4,16), vec4(4,4,8,16), vec4(8,4,12,16), vec4(12,4,16,16), - vec4(4,0,7,4), vec4(7,0,10,4), vec4(0,4,4,16), vec4(4,4,7,16), vec4(7,4,11,16), vec4(11,4,14,16), - vec4(4,0,12,4), vec4(12,0,20,4), vec4(0,4,4,16), vec4(4,4,12,16), vec4(12,4,16,16), vec4(16,4,24,16) + vec4(4.0, 0.0, 8.0, 4.0), // 4x4x12 + vec4(8.0, 0.0, 12.0, 4.0), + vec4(0.0, 4.0, 4.0, 16.0), + vec4(4.0, 4.0, 8.0, 16.0), + vec4(8.0, 4.0, 12.0, 16.0), + vec4(12.0, 4.0, 16.0, 16.0), + vec4(4.0, 0.0, 7.0, 4.0), // 4x3x12 + vec4(7.0, 0.0, 10.0, 4.0), + vec4(0.0, 4.0, 4.0, 16.0), + vec4(4.0, 4.0, 7.0, 16.0), + vec4(7.0, 4.0, 11.0, 16.0), + vec4(11.0, 4.0, 14.0, 16.0), + vec4(4.0, 0.0, 12.0, 4.0), // 4x8x12 + vec4(12.0, 0.0, 20.0, 4.0), + vec4(0.0, 4.0, 4.0, 16.0), + vec4(4.0, 4.0, 12.0, 16.0), + vec4(12.0, 4.0, 16.0, 16.0), + vec4(16.0, 4.0, 24.0, 16.0) ); + const vec2[] origins = vec2[]( - vec2(40,16), vec2(40,32), vec2(32,48), vec2(48,48), vec2(16,16), - vec2(16,32), vec2(0,16), vec2(0,32), vec2(16,48), vec2(0,48) + vec2(40.0, 16.0), // right arm + vec2(40.0, 32.0), + vec2(32.0, 48.0), // left arm + vec2(48.0, 48.0), + vec2(16.0, 16.0), // torso + vec2(16.0, 32.0), + vec2(0.0, 16.0), // right leg + vec2(0.0, 32.0), + vec2(16.0, 48.0), // left leg + vec2(0.0, 48.0) ); void main() { - vec3 position = Position; + gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0); + + + sphericalVertexDistance = fog_spherical_distance(Position); + cylindricalVertexDistance = fog_cylindrical_distance(Position); + + #ifdef NO_CARDINAL_LIGHTING + vertexColor = Color; + #else + vertexColor = minecraft_mix_light(Light0_Direction, Light1_Direction, Normal, Color); + #endif + + #ifndef EMISSIVE + lightMapColor = texelFetch(Sampler2, UV2 / 16, 0); + #endif + + overlayColor = texelFetch(Sampler1, UV1, 0); texCoord0 = UV0; - texCoord1 = vec2(0); - playerPart = 0; + + #ifdef APPLY_TEXTURE_MATRIX + texCoord0 = (TextureMat * vec4(UV0, 0.0, 1.0)).xy; + #endif ivec2 dim = textureSize(Sampler0, 0); - if (ProjMat[2][3] != 0.0 && dim == ivec2(64)) { - int partId = -int((Position.y - MAXRANGE) / SPACING); - playerPart = partId; - if (partId > 0 && partId <= 5) { - vec4 samp1 = texture(Sampler0, vec2(54.0/64.0, 20.0/64.0)); - vec4 samp2 = texture(Sampler0, vec2(55.0/64.0, 20.0/64.0)); - bool slim = samp1.a == 0.0 || (all(equal(samp1.rgb, vec3(0))) && all(equal(samp2.rgb, vec3(0))) && samp1.a == 1.0 && samp2.a == 1.0); + + if (ProjMat[2][3] == 0.0 || dim.x != 64 || dim.y != 64) { + part = 0.0; + texCoord1 = vec2(0.0); + } else { + vec3 wpos = Position; + vec2 UVout = UV0; + vec2 UVout2 = vec2(0.0); + int partId = - int((Position.y - MAXRANGE) / SPACING); + + part = float(partId); + + if (partId != 0) { + vec4 samp1 = texture(Sampler0, vec2(54.0 / 64.0, 20.0 / 64.0)); + vec4 samp2 = texture(Sampler0, vec2(55.0 / 64.0, 20.0 / 64.0)); + bool slim = samp1.a == 0.0 || (((samp1.r + samp1.g + samp1.b) == 0.0) && ((samp2.r + samp2.g + samp2.b) == 0.0) && samp1.a == 1.0 && samp2.a == 1.0); int outerLayer = (gl_VertexID / 24) % 2; int faceId = (gl_VertexID % 24) / 4; int vertexId = gl_VertexID % 4; - int subuvIndex = faceId + ((slim && partId <= 2) ? 6 : (partId == 3 ? 12 : 0)); - position.y += SPACING * partId; - vec2 uv = origins[2 * (partId - 1) + outerLayer]; - vec2 uv2 = origins[2 * (partId - 1)]; - vec4 s = subuvs[subuvIndex]; - vec2 offset; + int subuvIndex = faceId; + + wpos.y += SPACING * partId; + gl_Position = ProjMat * ModelViewMat * vec4(wpos, 1.0); + + UVout = origins[2 * (partId - 1) + outerLayer]; + UVout2 = origins[2 * (partId - 1)]; + + if (slim && (partId == 1 || partId == 2)) { + subuvIndex += 6; + } else if (partId == 3) { + subuvIndex += 12; + } + + vec4 subuv = subuvs[subuvIndex]; + vec2 offset = vec2(0.0); + if (faceId == 1) { - offset = vertexId == 0 ? s.zw : vertexId == 1 ? s.xw : vertexId == 2 ? s.xy : s.zy; + if (vertexId == 0) { + offset += subuv.zw; + } else if (vertexId == 1) { + offset += subuv.xw; + } else if (vertexId == 2) { + offset += subuv.xy; + } else { + offset += subuv.zy; + } } else { - offset = vertexId == 0 ? s.zy : vertexId == 1 ? s.xy : vertexId == 2 ? s.xw : s.zw; + if (vertexId == 0) { + offset += subuv.zy; + } else if (vertexId == 1) { + offset += subuv.xy; + } else if (vertexId == 2) { + offset += subuv.xw; + } else { + offset += subuv.zw; + } } - texCoord0 = (uv + offset) / 64.0; - texCoord1 = (uv2 + offset) / 64.0; + + UVout += offset; + UVout2 += offset; + UVout /= 64.0; + UVout2 /= 64.0; + } else { + // gl_Position = ProjMat * ModelViewMat * vec4(Position + vec3(0, 5, 0), 1.0); } + + sphericalVertexDistance = fog_spherical_distance(wpos); + cylindricalVertexDistance = fog_cylindrical_distance(wpos); + texCoord0 = UVout; + texCoord1 = UVout2; } - gl_Position = ProjMat * ModelViewMat * vec4(position, 1.0); - sphericalVertexDistance = fog_spherical_distance(position); - cylindricalVertexDistance = fog_cylindrical_distance(position); -#ifdef PER_FACE_LIGHTING - vec2 light = minecraft_compute_light(Light0_Direction, Light1_Direction, Normal); - vertexPerFaceColorBack = minecraft_mix_light_separate(-light, Color); - vertexPerFaceColorFront = minecraft_mix_light_separate(light, Color); -#elif defined(NO_CARDINAL_LIGHTING) - vertexColor = Color; -#else - vertexColor = minecraft_mix_light(Light0_Direction, Light1_Direction, Normal, Color); -#endif -#ifndef EMISSIVE - lightMapColor = sample_lightmap(Sampler2, UV2); -#endif -#ifndef NO_OVERLAY - overlayColor = texelFetch(Sampler1, UV1, 0); -#endif -#ifdef APPLY_TEXTURE_MATRIX - texCoord0 = (TextureMat * vec4(texCoord0, 0.0, 1.0)).xy; -#endif } diff --git a/src/test/resources/resourcepack_template/pack.mcmeta b/src/test/resources/resourcepack_template/pack.mcmeta index f4d2c576..07938e0b 100644 --- a/src/test/resources/resourcepack_template/pack.mcmeta +++ b/src/test/resources/resourcepack_template/pack.mcmeta @@ -1,8 +1,6 @@ { "pack": { - "pack_format": 88, - "min_format": 88, - "max_format": 88, + "pack_format": 34, "description": "Generated with WSEE" } }