Skip to content

Commit 6df6feb

Browse files
feat(ravengard): skeleton knight and archer mobs with health bars and /spawn
Both rigs come from the tracked captures, the knight with its slash effect parts and the archer with its bow draw cycle, their attack windows cut from the slash burst and draw timings in the logs. Mobs chase the nearest player at the captured walk speed, swing in reach, take knockback away from the attacker on hit, and die when the captured ten square health bar above them drains, a scaled down billboarded text display in the stat green as the tracked skeleton carried. Staff spawn them with /spawn, tab completing both types.
1 parent 4748718 commit 6df6feb

6 files changed

Lines changed: 334 additions & 0 deletions

File tree

configuration/ravengard/mobs/skeleton_archer.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

configuration/ravengard/mobs/skeleton_knight.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

type.ravengardgeneric/src/main/java/net/swofty/type/ravengardgeneric/RavengardGenericLoader.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public void initialize(MinecraftServer server) {
4747

4848
net.swofty.type.ravengardgeneric.item.RavengardItemRegistry.load();
4949
net.swofty.type.ravengardgeneric.shop.RavengardShopRegistry.load();
50+
net.swofty.type.ravengardgeneric.entity.mob.RavengardMob.startTicking();
5051
connectRegions();
5152
net.swofty.type.ravengardgeneric.region.RavengardRegion.cacheRegions();
5253
net.swofty.type.ravengardgeneric.region.RavengardRegionTracker.start();
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package net.swofty.type.ravengardgeneric.commands;
2+
3+
import net.minestom.server.command.builder.arguments.ArgumentType;
4+
import net.minestom.server.command.builder.suggestion.SuggestionEntry;
5+
import net.swofty.type.generic.command.CommandParameters;
6+
import net.swofty.type.generic.command.HypixelCommand;
7+
import net.swofty.type.generic.user.categories.Rank;
8+
import net.swofty.type.ravengardgeneric.entity.animation.RavengardAnimationClip;
9+
import net.swofty.type.ravengardgeneric.entity.mob.RavengardMob;
10+
import net.swofty.type.ravengardgeneric.user.RavengardPlayer;
11+
12+
import java.util.List;
13+
14+
@CommandParameters(
15+
labels = "spawn",
16+
description = "Spawns a Ravengard mob at your position",
17+
usage = "/spawn <mob>",
18+
permission = Rank.STAFF,
19+
allowsConsole = false)
20+
public class SpawnCommand extends HypixelCommand {
21+
private static final List<String> MOBS = List.of("skeleton_knight", "skeleton_archer");
22+
23+
@Override
24+
public void registerUsage(MinestomCommand command) {
25+
var mobArg = ArgumentType.Word("mob").setSuggestionCallback((sender, context, suggestion) ->
26+
MOBS.forEach(name -> suggestion.addEntry(new SuggestionEntry(name))));
27+
28+
command.addSyntax((sender, context) -> {
29+
if (!permissionCheck(sender)) return;
30+
RavengardPlayer player = (RavengardPlayer) sender;
31+
String name = context.get(mobArg);
32+
if (!MOBS.contains(name)) {
33+
player.sendMessage("§cUnknown mob. Options: " + String.join(", ", MOBS));
34+
return;
35+
}
36+
RavengardAnimationClip clip = RavengardAnimationClip.loadMob(name);
37+
RavengardMob mob = new RavengardMob(clip, player.getPosition());
38+
mob.spawn(player.getInstance());
39+
player.sendMessage("§aSpawned §f" + name + "§a.");
40+
}, mobArg);
41+
}
42+
}

type.ravengardgeneric/src/main/java/net/swofty/type/ravengardgeneric/entity/animation/RavengardAnimationClip.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public double roamRadius() {
4040
private String onComplete;
4141
private String shop;
4242
private String shopLine;
43+
private double health;
4344

4445
public double[] position() {
4546
return position == null ? new double[]{0, 0, 0} : position;
@@ -71,6 +72,23 @@ public String shopLine() {
7172
return shopLine;
7273
}
7374

75+
public double health() {
76+
return health;
77+
}
78+
79+
/** Mob clips live in their own directory beside the npc animations. */
80+
public static RavengardAnimationClip loadMob(String name) {
81+
return CACHE.computeIfAbsent("mob:" + name, key -> {
82+
java.io.File file = new java.io.File("./configuration/ravengard/mobs", name + ".json");
83+
try (InputStream stream = new java.io.FileInputStream(file)) {
84+
return GSON.fromJson(new InputStreamReader(stream, StandardCharsets.UTF_8),
85+
RavengardAnimationClip.class);
86+
} catch (Exception exception) {
87+
throw new IllegalStateException("Failed to load mob clip " + key, exception);
88+
}
89+
});
90+
}
91+
7492
public Dialogue dialogue() {
7593
return dialogue;
7694
}
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package net.swofty.type.ravengardgeneric.entity.mob;
2+
3+
import net.kyori.adventure.text.Component;
4+
import net.kyori.adventure.text.format.TextColor;
5+
import net.minestom.server.MinecraftServer;
6+
import net.minestom.server.coordinate.Pos;
7+
import net.minestom.server.coordinate.Vec;
8+
import net.minestom.server.entity.Entity;
9+
import net.minestom.server.entity.EntityType;
10+
import net.minestom.server.entity.LivingEntity;
11+
import net.minestom.server.entity.Player;
12+
import net.minestom.server.entity.metadata.display.AbstractDisplayMeta;
13+
import net.minestom.server.entity.metadata.display.ItemDisplayMeta;
14+
import net.minestom.server.entity.metadata.display.TextDisplayMeta;
15+
import net.minestom.server.instance.Instance;
16+
import net.minestom.server.item.ItemStack;
17+
import net.minestom.server.item.Material;
18+
import net.minestom.server.component.DataComponents;
19+
import net.swofty.type.generic.HypixelGenericLoader;
20+
import net.swofty.type.generic.entity.InteractionEntity;
21+
import net.swofty.type.ravengardgeneric.entity.animation.RavengardAnimationClip;
22+
23+
import java.util.ArrayList;
24+
import java.util.List;
25+
import java.util.concurrent.CopyOnWriteArrayList;
26+
27+
/**
28+
* A combat mob driven by a captured rig: the clip's idle and attack tracks animate the parts,
29+
* it chases the nearest player and swings when in reach, and the captured ten square health bar
30+
* floats above it, draining as it takes hits.
31+
*/
32+
public class RavengardMob {
33+
private static final List<RavengardMob> MOBS = new CopyOnWriteArrayList<>();
34+
private static final TextColor BAR_FULL = TextColor.color(0x5FEC7B);
35+
private static final TextColor BAR_EMPTY = TextColor.color(0x3D3D3D);
36+
private static final int BAR_SEGMENTS = 10;
37+
private static final double CHASE_RANGE = 14;
38+
private static final double ATTACK_RANGE = 2.4;
39+
private static final float PLAYER_DAMAGE_PER_HIT = 8f;
40+
private static final int HIT_DAMAGE = 20;
41+
private static final int ATTACK_COOLDOWN_TICKS = 30;
42+
43+
private final RavengardAnimationClip clip;
44+
private final List<Entity> parts = new ArrayList<>();
45+
private final List<Vec> partOffsets = new ArrayList<>();
46+
private LivingEntity healthBar;
47+
private InteractionEntity hitbox;
48+
private Instance instance;
49+
50+
private Pos position;
51+
private double health;
52+
private final double maxHealth;
53+
private int frame;
54+
private int attackTicksLeft;
55+
private int attackCooldown;
56+
private Vec knockback = Vec.ZERO;
57+
58+
public RavengardMob(RavengardAnimationClip clip, Pos position) {
59+
this.clip = clip;
60+
this.position = position;
61+
this.maxHealth = clip.health() <= 0 ? 100 : clip.health();
62+
this.health = maxHealth;
63+
}
64+
65+
public static List<RavengardMob> mobs() {
66+
return MOBS;
67+
}
68+
69+
public void spawn(Instance instance) {
70+
this.instance = instance;
71+
for (RavengardAnimationClip.Part part : clip.parts()) {
72+
RavengardAnimationClip.Base base = part.base();
73+
Entity display = new Entity(EntityType.ITEM_DISPLAY);
74+
display.setNoGravity(true);
75+
display.editEntityMeta(ItemDisplayMeta.class, meta -> {
76+
meta.setItemStack(ItemStack.builder(Material.LEATHER_BOOTS)
77+
.set(DataComponents.ITEM_MODEL, base.model()).build());
78+
ItemDisplayMeta.DisplayContext[] contexts = ItemDisplayMeta.DisplayContext.values();
79+
int contextOrdinal = base.itemDisplayContext();
80+
meta.setDisplayContext(contexts[contextOrdinal >= 0 && contextOrdinal < contexts.length
81+
? contextOrdinal : 0]);
82+
meta.setTranslation(vec(base.translation()));
83+
meta.setScale(vec(base.scale()));
84+
meta.setLeftRotation(base.leftRotation());
85+
meta.setRightRotation(base.rightRotation());
86+
meta.setTransformationInterpolationDuration(clip.interpolationDuration());
87+
meta.setPosRotInterpolationDuration(2);
88+
meta.setViewRange((float) base.viewRange());
89+
});
90+
double[] off = base.offset();
91+
Vec offset = new Vec(off[0], off[1], off[2]);
92+
display.setInstance(instance, position.add(offset));
93+
parts.add(display);
94+
partOffsets.add(offset);
95+
}
96+
97+
healthBar = new LivingEntity(EntityType.TEXT_DISPLAY);
98+
healthBar.setNoGravity(true);
99+
healthBar.editEntityMeta(TextDisplayMeta.class, meta -> {
100+
meta.setText(barText());
101+
meta.setScale(new Vec(0.3, 0.3, 0.3));
102+
meta.setBillboardRenderConstraints(AbstractDisplayMeta.BillboardConstraints.CENTER);
103+
meta.setBackgroundColor(0);
104+
meta.setHasNoGravity(true);
105+
});
106+
healthBar.setInstance(instance, position.add(0, 2.25, 0));
107+
108+
hitbox = new InteractionEntity(0.9f, 2.0f, (player, event) -> hit(player));
109+
hitbox.setInstance(instance, position);
110+
111+
MOBS.add(this);
112+
}
113+
114+
private Component barText() {
115+
int full = (int) Math.ceil(BAR_SEGMENTS * Math.max(0, health) / maxHealth);
116+
return Component.text("\u25A0".repeat(full)).color(BAR_FULL)
117+
.append(Component.text("\u25A0".repeat(BAR_SEGMENTS - full)).color(BAR_EMPTY));
118+
}
119+
120+
public void hit(Player player) {
121+
if (health <= 0) {
122+
return;
123+
}
124+
health -= HIT_DAMAGE;
125+
Vec away = position.sub(player.getPosition()).asVec().withY(0);
126+
if (away.lengthSquared() > 0.001) {
127+
knockback = away.normalize().mul(0.45).withY(0.1);
128+
}
129+
healthBar.editEntityMeta(TextDisplayMeta.class, meta -> meta.setText(barText()));
130+
if (health <= 0) {
131+
remove();
132+
}
133+
}
134+
135+
public void remove() {
136+
parts.forEach(Entity::remove);
137+
if (healthBar != null) healthBar.remove();
138+
if (hitbox != null) hitbox.remove();
139+
MOBS.remove(this);
140+
}
141+
142+
public void tick() {
143+
if (instance == null || health <= 0) {
144+
return;
145+
}
146+
147+
Player target = nearestPlayer();
148+
boolean attacking = attackTicksLeft > 0;
149+
boolean moved = false;
150+
float yaw = position.yaw();
151+
152+
if (!attacking && target != null) {
153+
double distance = position.distance(target.getPosition());
154+
Vec toTarget = target.getPosition().sub(position).asVec().withY(0);
155+
if (toTarget.lengthSquared() > 0.01) {
156+
yaw = (float) Math.toDegrees(Math.atan2(-toTarget.x(), toTarget.z()));
157+
}
158+
if (distance <= ATTACK_RANGE && attackCooldown <= 0) {
159+
attackTicksLeft = attackFrames();
160+
attackCooldown = ATTACK_COOLDOWN_TICKS;
161+
frame = 0;
162+
target.damage(net.minestom.server.entity.damage.DamageType.MOB_ATTACK, PLAYER_DAMAGE_PER_HIT);
163+
} else if (distance > ATTACK_RANGE && distance <= CHASE_RANGE && clip.walkSpeed() > 0) {
164+
Vec step = toTarget.normalize().mul(clip.walkSpeed());
165+
position = position.add(step.x(), 0, step.z()).withYaw(yaw);
166+
moved = true;
167+
} else {
168+
position = position.withYaw(yaw);
169+
}
170+
}
171+
172+
if (knockback.lengthSquared() > 0.001) {
173+
position = position.add(knockback.x(), 0, knockback.z());
174+
knockback = knockback.mul(0.6);
175+
moved = true;
176+
}
177+
178+
if (attackCooldown > 0) attackCooldown--;
179+
180+
String phase = attacking ? "talk" : "idle";
181+
List<RavengardAnimationClip.Part> clipParts = clip.parts();
182+
for (int i = 0; i < parts.size() && i < clipParts.size(); i++) {
183+
Entity part = parts.get(i);
184+
List<RavengardAnimationClip.Frame> frames = clipParts.get(i).phase(
185+
attacking ? net.swofty.type.ravengardgeneric.entity.animation.RavengardAnimationPhase.TALK
186+
: net.swofty.type.ravengardgeneric.entity.animation.RavengardAnimationPhase.IDLE);
187+
Pos partPos = position.add(rotateOffset(partOffsets.get(i), yaw)).withYaw(yaw);
188+
if (moved || attacking) {
189+
part.teleport(partPos);
190+
}
191+
if (frames != null && !frames.isEmpty()) {
192+
final RavengardAnimationClip.Frame animationFrame = frames.get(frame % frames.size());
193+
final float[] rotated = rotateRig(animationFrame.leftRotation(), yaw);
194+
final float finalYaw = yaw;
195+
part.editEntityMeta(ItemDisplayMeta.class, meta -> {
196+
meta.setTransformationInterpolationStartDelta(0);
197+
meta.setTranslation(rotateTranslation(vec(animationFrame.translation()), finalYaw));
198+
meta.setLeftRotation(rotated);
199+
});
200+
}
201+
}
202+
if (moved || attacking) {
203+
healthBar.teleport(position.add(0, 2.25, 0));
204+
hitbox.teleport(position);
205+
}
206+
207+
frame++;
208+
if (attacking) {
209+
attackTicksLeft--;
210+
if (attackTicksLeft <= 0) frame = 0;
211+
}
212+
}
213+
214+
private int attackFrames() {
215+
int max = 0;
216+
for (RavengardAnimationClip.Part part : clip.parts()) {
217+
List<RavengardAnimationClip.Frame> frames = part.phase(
218+
net.swofty.type.ravengardgeneric.entity.animation.RavengardAnimationPhase.TALK);
219+
if (frames != null) max = Math.max(max, frames.size());
220+
}
221+
return Math.max(1, max);
222+
}
223+
224+
private Player nearestPlayer() {
225+
Player nearest = null;
226+
double best = CHASE_RANGE * CHASE_RANGE;
227+
for (var player : HypixelGenericLoader.getLoadedPlayers()) {
228+
if (player.getInstance() != instance) continue;
229+
double distance = player.getPosition().distanceSquared(position);
230+
if (distance < best) {
231+
best = distance;
232+
nearest = player;
233+
}
234+
}
235+
return nearest;
236+
}
237+
238+
private static Vec vec(float[] values) {
239+
return new Vec(values[0], values[1], values[2]);
240+
}
241+
242+
private static Vec rotateOffset(Vec offset, float yaw) {
243+
double theta = Math.toRadians(yaw);
244+
double sin = Math.sin(theta), cos = Math.cos(theta);
245+
return new Vec(offset.x() * cos + offset.z() * sin, offset.y(),
246+
-offset.x() * sin + offset.z() * cos);
247+
}
248+
249+
private static Vec rotateTranslation(Vec translation, float yaw) {
250+
return rotateOffset(translation, yaw);
251+
}
252+
253+
private static float[] rotateRig(float[] rotation, float yaw) {
254+
double theta = Math.toRadians(-yaw) / 2.0;
255+
float sin = (float) Math.sin(theta), cos = (float) Math.cos(theta);
256+
float rx = rotation[0], ry = rotation[1], rz = rotation[2], rw = rotation[3];
257+
return new float[]{cos * rx + sin * rz, cos * ry + sin * rw, cos * rz - sin * rx, cos * rw - sin * ry};
258+
}
259+
260+
public static void startTicking() {
261+
MinecraftServer.getSchedulerManager()
262+
.buildTask(() -> MOBS.forEach(mob -> {
263+
try {
264+
mob.tick();
265+
} catch (Exception ignored) {
266+
}
267+
}))
268+
.repeat(net.minestom.server.timer.TaskSchedule.tick(1))
269+
.schedule();
270+
}
271+
}

0 commit comments

Comments
 (0)