Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ publishing {
}

dependencies {
testImplementation(platform("org.junit:junit-bom:6.0.0-M2"))
testImplementation(libs.junit.api)
testRuntimeOnly(libs.junit.engine)
testRuntimeOnly("org.junit.platform:junit-platform-launcher")

compileOnly(libs.minestom)
testImplementation(libs.minestom)
Expand All @@ -50,8 +52,6 @@ dependencies {

implementation(libs.javax.json.api)
implementation(libs.javax.json)

implementation(libs.mql)
}

tasks.test {
Expand Down
4 changes: 1 addition & 3 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
[versions]
java = "25"
junit = "6.0.0-M2"
minestom = "2026.04.13-1.21.11"
minestom = "2026.07.01-26.1.2"
commons-io = "2.20.0"
zt-zip = "1.17"
javax-json = "1.1.4"
mql = "1.0.1"

[libraries]
minestom = { module = "net.minestom:minestom", version.ref = "minestom" }
Expand All @@ -15,4 +14,3 @@ commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" }
zt-zip = { module = "org.zeroturnaround:zt-zip", version.ref = "zt-zip" }
javax-json-api = { module = "javax.json:javax.json-api", version.ref = "javax-json" }
javax-json = { module = "org.glassfish:javax.json", version.ref = "javax-json" }
mql = { module = "dev.hollowcube:mql", version.ref = "mql" }
12 changes: 2 additions & 10 deletions src/main/java/net/worldseed/gestures/EmotePlayer.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
import net.minestom.server.instance.Instance;
import net.worldseed.multipart.animations.AnimationHandler;
import net.worldseed.multipart.animations.AnimationHandlerImpl;
import net.worldseed.multipart.events.ModelDamageEvent;
import net.worldseed.multipart.events.ModelInteractEvent;
import org.jetbrains.annotations.NotNull;

import java.util.Map;
Expand Down Expand Up @@ -47,14 +45,8 @@ protected void loadDefaultAnimations() {
}
};

this.eventNode().addListener(EntityDamageEvent.class, (event) -> {
event.setCancelled(true);
ModelDamageEvent modelDamageEvent = new ModelDamageEvent(model, event);
EventDispatcher.call(modelDamageEvent);
}).addListener(PlayerEntityInteractEvent.class, (event) -> {
ModelInteractEvent modelInteractEvent = new ModelInteractEvent(model, event);
EventDispatcher.call(modelInteractEvent);
});
// Hits on the emote model surface as normal Minestom events; keep the emote itself invulnerable.
this.eventNode().addListener(EntityDamageEvent.class, event -> event.setCancelled(true));

this.model.draw();
this.model.draw();
Expand Down
54 changes: 54 additions & 0 deletions src/main/java/net/worldseed/multipart/GenericModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ public interface GenericModel extends Viewable, EventHandler<@NonNull ModelEvent
*/
void setState(String state);

/**
* Spawn the model's bones into the given instance at the given position. Usually called for you by
* {@link ModelEntity}; call it directly only when driving a model without a {@link ModelEntity}.
*
* @param instance the instance to spawn in
* @param position the position to spawn at
*/
void init(@Nullable Instance instance, @NotNull Pos position);

/**
* Destroy the model
*/
Expand All @@ -113,9 +122,27 @@ public interface GenericModel extends Viewable, EventHandler<@NonNull ModelEvent
*/
Point getVFX(String name);

/**
* Get a Blockbench locator's current world position, or null if there's no such locator/bone. Useful for
* anchoring particles, projectiles or attached entities at authored points.
*
* @param name the locator (or bone) name
* @return the world position, or null
*/
Point getLocator(String name);

@ApiStatus.Internal
ModelBone getPart(String boneName);

/**
* Show or hide a bone at runtime (e.g. damage states, equipment, phase changes). A hidden bone's
* display entity is removed for all current and future viewers until shown again.
*
* @param boneName the bone to toggle
* @param visible true to show, false to hide
*/
void setBoneVisible(String boneName, boolean visible);

@ApiStatus.Internal
void draw();

Expand All @@ -131,6 +158,22 @@ public interface GenericModel extends Viewable, EventHandler<@NonNull ModelEvent

Instance getInstance();

/**
* The entity that owns/drives this model — the source of its position and viewers, and the entity
* that receives hits landed on the model's hitboxes (as normal Minestom events). Set automatically
* when the model is bound to a {@link ModelEntity}; null if the model is driven manually.
*
* @return the owning entity, or null
*/
@Nullable Entity getOwner();

/**
* Set the owning entity (see {@link #getOwner()}).
*
* @param owner the owning entity, or null to unbind
*/
void setOwner(@Nullable Entity owner);

Point getOffset(String bone);

Point getDiff(String bone);
Expand All @@ -157,6 +200,17 @@ public interface GenericModel extends Viewable, EventHandler<@NonNull ModelEvent

void bindNametag(String name, Entity nametag);

/**
* Create a floating {@link net.minestom.server.entity.EntityType#TEXT_DISPLAY} showing {@code text} and
* bind it to the given nametag bone — the modern replacement for attaching an armor stand. Returns the
* created entity so you can tweak its meta (background, billboard, line width…).
*
* @param name the nametag bone name
* @param text the text to show
* @return the created text-display entity
*/
Entity setNametag(String name, net.kyori.adventure.text.Component text);

void unbindNametag(String name);

@Nullable Entity getNametag(String name);
Expand Down
48 changes: 46 additions & 2 deletions src/main/java/net/worldseed/multipart/GenericModelImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,11 @@ protected void loadBones(JsonObject loadedModel, float scale) {
Point boneRotation = ModelEngine.getPos(bone.getAsJsonObject().get("rotation")).orElse(Pos.ZERO).mul(-1, -1, 1);
Point pivotPos = ModelEngine.getPos(pivot).orElse(Pos.ZERO).mul(-1, 1, 1);

if (bone.getAsJsonObject().has("locator")) { // position-only locator bone (see getLocator)
parts.put(name, new ModelBoneVFX(pivotPos, name, boneRotation, this, scale));
continue;
}

boolean found = false;
for (Map.Entry<Predicate<String>, Function<ModelBoneInfo, @Nullable ModelBone>> entry : this.boneSuppliers.entrySet()) {
var predicate = entry.getKey();
Expand Down Expand Up @@ -231,6 +236,18 @@ public Instance getInstance() {
return instance;
}

private Entity owner;

@Override
public Entity getOwner() {
return owner;
}

@Override
public void setOwner(Entity owner) {
this.owner = owner;
}

public void setState(String state) {
for (ModelBoneImpl part : viewableBones) {
part.setState(state);
Expand All @@ -242,6 +259,7 @@ public ModelBone getPart(String boneName) {
}

public void draw() {
net.worldseed.multipart.model_bones.ModelBoneImpl.beginDrawFrame();
for (ModelBone modelBonePart : this.parts.values()) {
if (modelBonePart.getParent() == null)
modelBonePart.draw();
Expand All @@ -264,6 +282,18 @@ public Point getVFX(String name) {
return found.getPosition();
}

@Override
public void setBoneVisible(String boneName, boolean visible) {
ModelBone bone = this.parts.get(boneName);
if (bone != null) bone.setVisible(visible);
}

@Override
public Point getLocator(String name) {
ModelBone found = this.parts.get(name);
return found == null ? null : found.getPosition();
}

@Override
public void setHeadRotation(String name, double rotation) {
ModelBone found = this.parts.get(name);
Expand Down Expand Up @@ -308,7 +338,7 @@ public void sendPacketToViewers(@NotNull SendablePacket packet) {
}

@Override
public void sendPacketsToViewers(@NotNull Collection<SendablePacket> packets) {
public void sendPacketsToViewers(@NotNull Collection<? extends SendablePacket> packets) {
for (Player viewer : this.viewers) {
for (SendablePacket packet : packets) {
viewer.sendPacket(packet);
Expand Down Expand Up @@ -370,7 +400,9 @@ public void addPartsAsPassengers(Player player) {

@Override
public @NotNull Set<@NotNull Player> getViewers() {
return Set.copyOf(this.viewers);
// live unmodifiable view (viewers is a concurrent key-set, safe to iterate) instead of a fresh
// Set.copyOf on every call — the per-tick metadata flush of every bone reads this.
return java.util.Collections.unmodifiableSet(this.viewers);
}

@Override
Expand Down Expand Up @@ -503,6 +535,18 @@ public void bindNametag(String name, Entity nametag) {
if (this.parts.get(name) instanceof ModelBoneNametag nametagBone) nametagBone.bind(nametag);
}

@Override
public Entity setNametag(String name, net.kyori.adventure.text.Component text) {
Entity display = new Entity(net.minestom.server.entity.EntityType.TEXT_DISPLAY);
var meta = (net.minestom.server.entity.metadata.display.TextDisplayMeta) display.getEntityMeta();
meta.setText(text);
meta.setBillboardRenderConstraints(net.minestom.server.entity.metadata.display.AbstractDisplayMeta.BillboardConstraints.CENTER);
display.setNoGravity(true);
if (getInstance() != null) display.setInstance(getInstance(), getPosition());
bindNametag(name, display);
return display;
}

@Override
public void unbindNametag(String name) {
if (this.parts.get(name) instanceof ModelBoneNametag nametagBone) nametagBone.unbind();
Expand Down
19 changes: 12 additions & 7 deletions src/main/java/net/worldseed/multipart/ModelEngine.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import net.minestom.server.coordinate.Pos;
import net.minestom.server.coordinate.Vec;
import net.minestom.server.entity.Entity;
import net.minestom.server.entity.LivingEntity;
import net.minestom.server.event.EventDispatcher;
import net.minestom.server.event.EventListener;
import net.minestom.server.event.entity.EntityDamageEvent;
Expand All @@ -17,8 +18,6 @@
import net.minestom.server.item.component.CustomModelData;
import net.minestom.server.network.packet.client.play.ClientInputPacket;
import net.worldseed.multipart.events.ModelControlEvent;
import net.worldseed.multipart.events.ModelDamageEvent;
import net.worldseed.multipart.events.ModelInteractEvent;
import net.worldseed.multipart.model_bones.BoneEntity;
import net.worldseed.multipart.mql.MQLPoint;
import org.jspecify.annotations.NonNull;
Expand All @@ -45,17 +44,23 @@ public class ModelEngine {
}
}
});
// A hit on a model hitbox (an INTERACTION/BoneEntity) is surfaced as a NORMAL Minestom event on the
// model's owner entity (see GenericModel#getOwner / ModelEntity) — no custom WSEE events to hook.
private static final EventListener<@NonNull PlayerEntityInteractEvent> playerInteractListener = EventListener.of(PlayerEntityInteractEvent.class, event -> {
if (event.getTarget() instanceof BoneEntity bone) {
ModelInteractEvent modelInteractEvent = new ModelInteractEvent(bone.getModel(), event, bone);
EventDispatcher.call(modelInteractEvent);
Entity owner = bone.getModel().getOwner();
if (owner != null && owner != event.getTarget()) {
EventDispatcher.call(new PlayerEntityInteractEvent(event.getPlayer(), owner, event.getHand(), event.getInteractPosition()));
}
}
});
private static final EventListener<@NonNull EntityDamageEvent> entityDamageListener = EventListener.of(EntityDamageEvent.class, event -> {
if (event.getEntity() instanceof BoneEntity bone) {
event.setCancelled(true);
ModelDamageEvent modelDamageEvent = new ModelDamageEvent(bone.getModel(), event, bone);
MinecraftServer.getGlobalEventHandler().call(modelDamageEvent);
event.setCancelled(true); // the hitbox entity itself never takes damage
Entity owner = bone.getModel().getOwner();
if (owner instanceof LivingEntity living && owner != bone) {
living.damage(event.getDamage()); // re-raises a normal EntityDamageEvent on the owner
}
}
});
private static Path modelPath;
Expand Down
79 changes: 79 additions & 0 deletions src/main/java/net/worldseed/multipart/ModelEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package net.worldseed.multipart;

import net.minestom.server.coordinate.Pos;
import net.minestom.server.entity.EntityCreature;
import net.minestom.server.entity.EntityType;
import net.minestom.server.entity.Player;
import net.minestom.server.instance.Instance;
import org.jetbrains.annotations.NotNull;

/**
* A real Minestom entity that owns and drives a {@link GenericModel}. Extend this instead of hand-wiring
* an invisible "carrier" entity to a model.
*
* <p>It removes the boilerplate that every WSEE mob used to repeat:
* <ul>
* <li><b>viewers</b> — the model is shown to exactly the players who can see this entity (auto-synced),</li>
* <li><b>position</b> — the model follows this entity every tick,</li>
* <li><b>lifecycle</b> — the model is destroyed with this entity,</li>
* <li><b>hits</b> — a hit on any of the model's hitboxes arrives as a <i>normal</i> Minestom
* {@link net.minestom.server.event.entity.EntityDamageEvent} /
* {@link net.minestom.server.event.player.PlayerEntityInteractEvent} on <b>this</b> entity
* (WSEE routes it via {@link GenericModel#getOwner()}); no custom events to hook.</li>
* </ul>
*
* <p>The carrier entity is made invisible by default so only the model is seen. Typical usage:
* <pre>{@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) ... });
* }</pre>
*/
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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package net.worldseed.multipart.animations;

/**
* A sound / particle / timeline effect authored on a Blockbench animation's "effects" track, resolved to a
* playback tick. Fired by the {@link AnimationHandler} while the owning animation plays (see
* {@link AnimationEffectHandler}).
*
* @param animation the animation this effect belongs to
* @param type SOUND, PARTICLE or TIMELINE
* @param tick when it fires, in ticks from the start of the animation (Blockbench time * 20)
* @param effect the effect id — a sound key (SOUND) or particle id (PARTICLE); null for TIMELINE
* @param locator the locator/VFX bone the effect is anchored to, or null for the model origin
* @param script the Molang/script instruction (TIMELINE, and optional particle script), or null
*/
public record AnimationEffect(String animation, Type type, int tick, String effect, String locator, String script) {
public enum Type { SOUND, PARTICLE, TIMELINE }
}
Loading